Switched stream status tracking to explicit online state.
CI / Formatting (push) Failing after 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 28s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 6s

This commit is contained in:
2026-05-21 16:09:28 -04:00
parent 4d0ae3d1ea
commit c837b916c0
11 changed files with 385 additions and 219 deletions
+16 -10
View File
@@ -262,7 +262,8 @@ class TestSubscriptionsCommand:
domain="stream.logal.dev",
name="Test Stream",
title="Playing Games",
last_connect_time="2026-01-01T12:00:00Z",
online=True,
status_since="2026-01-01T12:00:00+00:00",
)
)
@@ -297,7 +298,8 @@ class TestSubscriptionsCommand:
domain="stream.logal.dev",
name="*Bold* [link](https://evil.example)\nName",
title="`code` > quote #tag",
last_connect_time="2026-01-01T12:00:00Z",
online=True,
status_since="2026-01-01T12:00:00+00:00",
)
)
@@ -333,7 +335,7 @@ class TestSubscriptionsCommand:
StreamState(
domain="stream.logal.dev",
name="Test Stream",
last_disconnect_time="2026-01-01T10:00:00Z",
status_since="2026-01-01T10:00:00+00:00",
)
)
@@ -428,14 +430,15 @@ class TestSubscriptionsCommand:
domain="alpha.com",
name="Alpha Stream",
title="Streaming Live",
last_connect_time="2026-03-13T10:00:00Z",
online=True,
status_since="2026-03-13T10:00:00+00:00",
)
)
await maubot_plugin.stream_repo.update(
StreamState(
domain="beta.com",
name="Beta Stream",
last_disconnect_time="2026-03-12T18:00:00Z",
status_since="2026-03-12T18:00:00+00:00",
)
)
@@ -491,7 +494,7 @@ class TestLiveCommand:
StreamState(
domain="stream.logal.dev",
name="Test Stream",
last_disconnect_time="2026-01-01T10:00:00Z",
status_since="2026-01-01T10:00:00+00:00",
)
)
@@ -524,7 +527,8 @@ class TestLiveCommand:
domain="stream.logal.dev",
name="Test Stream",
title="Playing Games",
last_connect_time="2026-01-01T12:00:00Z",
online=True,
status_since="2026-01-01T12:00:00+00:00",
)
)
@@ -557,13 +561,14 @@ class TestLiveCommand:
await maubot_test_bot.send("!subscribe beta.com")
await maubot_test_bot.send("!subscribe alpha.com")
# Set both streams online with different connect times
# Set both streams online with different status timestamps
await maubot_plugin.stream_repo.update(
StreamState(
domain="alpha.com",
name="Alpha Stream",
title="Morning Show",
last_connect_time="2026-03-13T10:00:00Z",
online=True,
status_since="2026-03-13T10:00:00+00:00",
)
)
await maubot_plugin.stream_repo.update(
@@ -571,7 +576,8 @@ class TestLiveCommand:
domain="beta.com",
name="Beta Stream",
title="Evening Vibes",
last_connect_time="2026-03-13T06:00:00Z",
online=True,
status_since="2026-03-13T06:00:00+00:00",
)
)
+8 -9
View File
@@ -16,9 +16,11 @@
import json
import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING
import pytest
import time_machine
from aioresponses import aioresponses
from owncastsentry.metrics import MetricsService
@@ -112,17 +114,13 @@ class TestReadLimitedResponseBody:
response = _ChunkedResponse(
(
b'{"streamTitle":',
b'"hello","online":true,',
b'"lastConnectTime":null,"lastDisconnectTime":null}',
b'"hello","online":true}',
)
)
result = await _read_limited_response_body(response)
assert result == bytearray(
b'{"streamTitle":"hello","online":true,'
b'"lastConnectTime":null,"lastDisconnectTime":null}'
)
assert result == bytearray(b'{"streamTitle":"hello","online":true}')
async def test_returns_none_when_content_length_is_too_large(self) -> None:
"""Return None when Content-Length is already over the limit."""
@@ -152,6 +150,7 @@ class TestReadLimitedResponseBody:
class TestGetStreamState:
"""Stream state 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:
@@ -169,14 +168,14 @@ class TestGetStreamState:
result.title
== "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!" # noqa: E501
)
assert result.last_connect_time is None
assert result.last_disconnect_time == "2026-03-04T21:05:32-05:00"
assert result.online is False
assert result.status_since == "2026-03-13T12:00:00+00:00"
async def test_returns_none_on_missing_field(
self, owncast_client: OwncastClient
) -> None:
"""Return None when the response is missing required fields."""
incomplete = {"streamTitle": "Test Stream", "online": True}
incomplete = {"streamTitle": "Test Stream"}
with aioresponses() as mocked:
mocked.get(
"https://stream.logal.dev/api/status",
+61 -3
View File
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
import pytest
from owncastsentry.repository import _normalize_legacy_status_since
from owncastsentry.types import (
UNKNOWN_STATUS_THRESHOLD,
AlreadySubscribedError,
@@ -26,9 +27,64 @@ from owncastsentry.types import (
)
if TYPE_CHECKING:
from mautrix.util.async_db import Database
from owncastsentry.repository import StreamRepository, SubscriptionRepository
class TestStreamSchema:
"""Streams table schema after migrations."""
async def test_uses_online_and_status_since_columns(
self, database: Database
) -> None:
"""Create the current schema without legacy connect/disconnect columns."""
async with database.acquire() as conn:
rows = await conn.fetch("PRAGMA table_info(streams)")
assert [row["name"] for row in rows] == [
"domain",
"name",
"title",
"online",
"status_since",
"failure_counter",
]
class TestNormalizeLegacyStatusSince:
"""Legacy timestamp normalization used by the v4 migration."""
@pytest.mark.parametrize(
("value", "expected"),
[
pytest.param(
"2026-05-21T19:06:24Z",
"2026-05-21T19:06:24+00:00",
id="utc-z",
),
pytest.param(
"2026-05-21T19:38:40+02:00",
"2026-05-21T17:38:40+00:00",
id="positive-offset",
),
pytest.param(
"2026-05-17T21:23:27-04:00",
"2026-05-18T01:23:27+00:00",
id="negative-offset",
),
pytest.param("", None, id="blank"),
pytest.param(None, None, id="null"),
pytest.param("not a timestamp", None, id="malformed"),
],
)
def test_normalizes_parseable_aware_timestamps(
self, value: str | None, expected: str | None
) -> None:
"""Normalize parseable legacy values and ignore unusable ones."""
assert _normalize_legacy_status_since(value) == expected
class TestStreamExists:
"""Stream existence checks."""
@@ -174,7 +230,7 @@ class TestGetLiveRoomSubscriptions:
StreamState(
domain="offline.example",
name="Offline",
last_disconnect_time="2026-01-01T00:00:00Z",
status_since="2026-01-01T00:00:00+00:00",
)
)
await stream_repo.create("online.example")
@@ -182,7 +238,8 @@ class TestGetLiveRoomSubscriptions:
StreamState(
domain="online.example",
name="Online",
last_connect_time="2026-01-01T00:00:00Z",
online=True,
status_since="2026-01-01T00:00:00+00:00",
)
)
await stream_repo.create("unknown.example")
@@ -190,7 +247,8 @@ class TestGetLiveRoomSubscriptions:
StreamState(
domain="unknown.example",
name="Unknown",
last_connect_time="2026-01-01T00:00:00Z",
online=True,
status_since="2026-01-01T00:00:00+00:00",
)
)
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
+60 -48
View File
@@ -87,8 +87,8 @@ async def _seed_stream(
room_id: str = "!room:matrix.org",
name: str | None = "Test Stream",
title: str | None = "Test Title",
last_connect_time: str | None = None,
last_disconnect_time: str | None = None,
online: bool = False,
status_since: str | None = None,
) -> None:
"""Insert a stream and subscription into the database."""
await stream_repo.create(domain)
@@ -96,8 +96,8 @@ async def _seed_stream(
domain=domain,
name=name,
title=title,
last_connect_time=last_connect_time,
last_disconnect_time=last_disconnect_time,
online=online,
status_since=status_since,
)
await stream_repo.update(state)
await subscription_repo.add(domain, room_id)
@@ -224,7 +224,9 @@ class TestUpdateAllStreams:
"""Return an UpdateResult with correct success and failure counts."""
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="ok.com", last_connect_time="2026-01-01T00:00:00Z"
domain="ok.com",
online=True,
status_since="2026-01-01T00:00:00+00:00",
),
stream_config=StreamConfig(name="OK"),
)
@@ -291,8 +293,8 @@ class TestUpdateStreamFirstUpdate:
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="new.com",
last_connect_time="2026-01-01T00:00:00Z",
last_disconnect_time="2025-12-31T00:00:00Z",
online=True,
status_since="2026-01-01T00:00:00+00:00",
),
stream_config=StreamConfig(name="New Stream"),
)
@@ -304,7 +306,7 @@ class TestUpdateStreamFirstUpdate:
client=client,
)
# Seed with no connect/disconnect times (brand new)
# Seed with no status timestamp (brand new)
await stream_repo.create("new.com")
await subscription_repo.add("new.com", "!room:matrix.org")
@@ -321,7 +323,7 @@ class TestUpdateStreamFirstUpdate:
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="new.com",
last_disconnect_time="2025-12-31T00:00:00Z",
status_since="2025-12-31T00:00:00+00:00",
),
stream_config=StreamConfig(name="New Stream"),
)
@@ -354,8 +356,8 @@ class TestUpdateStreamGoesLive:
stream_state=StreamState(
domain="live.com",
title="Now Streaming",
last_connect_time="2026-01-01T12:00:00Z",
last_disconnect_time="2026-01-01T10:00:00Z",
online=True,
status_since="2026-01-01T12:00:00+00:00",
),
stream_config=StreamConfig(name="Live Stream", tags=("gaming",)),
)
@@ -367,12 +369,12 @@ class TestUpdateStreamGoesLive:
client=client,
)
# Seed as offline (has disconnect but no connect)
# Seed as offline.
await _seed_stream(
stream_repo,
subscription_repo,
domain="live.com",
last_disconnect_time="2026-01-01T10:00:00Z",
status_since="2026-01-01T10:00:00+00:00",
)
# Set offline timer to long ago so it's not a brief outage
@@ -402,8 +404,8 @@ class TestUpdateStreamGoesLive:
stream_state=StreamState(
domain="live.com",
title="Now Streaming",
last_connect_time="2026-01-01T12:00:00Z",
last_disconnect_time="2026-01-01T10:00:00Z",
online=True,
status_since="2026-01-01T12:00:00+00:00",
),
stream_config=None,
)
@@ -419,7 +421,7 @@ class TestUpdateStreamGoesLive:
stream_repo,
subscription_repo,
domain="live.com",
last_disconnect_time="2026-01-01T10:00:00Z",
status_since="2026-01-01T10:00:00+00:00",
)
monitor.offline_timer_cache["live.com"] = (
@@ -450,7 +452,8 @@ class TestUpdateStreamBriefOffline:
stream_state=StreamState(
domain="brief.com",
title="Same Title",
last_connect_time="2026-01-01T12:00:00Z",
online=True,
status_since="2026-01-01T12:00:00+00:00",
),
stream_config=StreamConfig(name="Brief Stream"),
)
@@ -468,7 +471,7 @@ class TestUpdateStreamBriefOffline:
subscription_repo,
domain="brief.com",
title="Same Title",
last_disconnect_time="2026-01-01T11:55:00Z",
status_since="2026-01-01T11:55:00+00:00",
)
# Recently offline (within cooldown)
@@ -488,7 +491,8 @@ class TestUpdateStreamBriefOffline:
stream_state=StreamState(
domain="brief.com",
title="New Title",
last_connect_time="2026-01-01T12:00:00Z",
online=True,
status_since="2026-01-01T12:00:00+00:00",
),
stream_config=StreamConfig(name="Brief Stream"),
)
@@ -506,7 +510,7 @@ class TestUpdateStreamBriefOffline:
subscription_repo,
domain="brief.com",
title="Old Title",
last_disconnect_time="2026-01-01T11:55:00Z",
status_since="2026-01-01T11:55:00+00:00",
)
# Recently offline (within cooldown)
@@ -536,7 +540,8 @@ class TestUpdateStreamTitleChange:
stream_state=StreamState(
domain="title.com",
title="Updated Title",
last_connect_time="2026-01-01T12:00:00Z",
online=True,
status_since="2026-01-01T12:00:00+00:00",
),
stream_config=None,
)
@@ -553,7 +558,8 @@ class TestUpdateStreamTitleChange:
subscription_repo,
domain="title.com",
title="Original Title",
last_connect_time="2026-01-01T12:00:00Z",
online=True,
status_since="2026-01-01T12:00:00+00:00",
)
now = time.monotonic()
@@ -585,7 +591,8 @@ class TestUpdateStreamTitleChange:
stream_state=StreamState(
domain="title.com",
title="Updated Title",
last_connect_time="2026-01-01T12:00:00Z",
online=True,
status_since="2026-01-01T12:00:00+00:00",
),
stream_config=StreamConfig(name="Title Stream"),
)
@@ -603,7 +610,8 @@ class TestUpdateStreamTitleChange:
subscription_repo,
domain="title.com",
title="Original Title",
last_connect_time="2026-01-01T12:00:00Z",
online=True,
status_since="2026-01-01T12:00:00+00:00",
)
# Last notification was long enough ago to pass rate limiting,
@@ -637,7 +645,8 @@ class TestUpdateStreamTitleChange:
stream_state=StreamState(
domain="title.com",
title="Updated Title",
last_connect_time="2026-01-01T12:00:00Z",
online=True,
status_since="2026-01-01T12:00:00+00:00",
),
stream_config=StreamConfig(name="Title Stream"),
)
@@ -654,7 +663,8 @@ class TestUpdateStreamTitleChange:
subscription_repo,
domain="title.com",
title="Original Title",
last_connect_time="2026-01-01T12:00:00Z",
online=True,
status_since="2026-01-01T12:00:00+00:00",
)
# Offline timer is MORE recent than last notification,
@@ -691,7 +701,7 @@ class TestUpdateStreamGoesOffline:
stream_state=StreamState(
domain="offline.com",
title="Title",
last_disconnect_time="2026-01-01T12:00:00Z",
status_since="2026-01-01T12:00:00+00:00",
),
stream_config=StreamConfig(name="Offline Stream"),
)
@@ -708,7 +718,8 @@ class TestUpdateStreamGoesOffline:
stream_repo,
subscription_repo,
domain="offline.com",
last_connect_time="2026-01-01T10:00:00Z",
online=True,
status_since="2026-01-01T10:00:00+00:00",
)
monitor.offline_timer_cache["offline.com"] = 0
@@ -743,7 +754,7 @@ class TestUpdateStreamConnectionFailure:
stream_repo,
subscription_repo,
domain="fail.com",
last_disconnect_time="2026-01-01T00:00:00Z",
status_since="2026-01-01T00:00:00+00:00",
)
result = await monitor.update_stream("fail.com")
@@ -865,7 +876,7 @@ class TestUpdateStreamNoStateChange:
stream_state=StreamState(
domain="stable.com",
title="Same Title",
last_disconnect_time="2026-01-01T12:00:00Z",
status_since="2026-01-01T12:00:00+00:00",
),
)
client = _StubMatrixClient()
@@ -876,13 +887,13 @@ class TestUpdateStreamNoStateChange:
client=client,
)
# Seed as offline with same disconnect time and title
# Seed as offline with same status timestamp and title.
await _seed_stream(
stream_repo,
subscription_repo,
domain="stable.com",
title="Same Title",
last_disconnect_time="2026-01-01T12:00:00Z",
status_since="2026-01-01T12:00:00+00:00",
)
result = await monitor.update_stream("stable.com")
@@ -905,7 +916,7 @@ class TestUpdateStreamFailureCounterReset:
stream_state=StreamState(
domain="recover.com",
title="Title",
last_disconnect_time="2026-01-01T12:00:00Z",
status_since="2026-01-01T12:00:00+00:00",
),
)
client = _StubMatrixClient()
@@ -921,7 +932,7 @@ class TestUpdateStreamFailureCounterReset:
subscription_repo,
domain="recover.com",
title="Title",
last_disconnect_time="2026-01-01T12:00:00Z",
status_since="2026-01-01T12:00:00+00:00",
)
# Simulate prior failures (counter=4 still passes backoff)
@@ -964,7 +975,7 @@ class TestUpdateAllStreamsMixed:
stream_repo,
subscription_repo,
domain="fail.com",
last_disconnect_time="2026-01-01T00:00:00Z",
status_since="2026-01-01T00:00:00+00:00",
)
# "skip.com" will be skipped via backoff (returns True)
@@ -973,7 +984,7 @@ class TestUpdateAllStreamsMixed:
subscription_repo,
domain="skip.com",
room_id="!room2:matrix.org",
last_disconnect_time="2026-01-01T00:00:00Z",
status_since="2026-01-01T00:00:00+00:00",
)
for _ in range(5):
await stream_repo.increment_failure_counter("skip.com")
@@ -993,7 +1004,7 @@ class TestUpdateAllStreamsMixed:
stream_state=StreamState(
domain="ok.com",
title="Title",
last_disconnect_time="2026-01-01T00:00:00Z",
status_since="2026-01-01T00:00:00+00:00",
),
)
# Patch get_stream_state to raise for one specific domain
@@ -1019,7 +1030,7 @@ class TestUpdateAllStreamsMixed:
stream_repo,
subscription_repo,
domain="raise.com",
last_disconnect_time="2026-01-01T00:00:00Z",
status_since="2026-01-01T00:00:00+00:00",
)
await _seed_stream(
stream_repo,
@@ -1027,7 +1038,7 @@ class TestUpdateAllStreamsMixed:
domain="ok.com",
room_id="!room2:matrix.org",
title="Title",
last_disconnect_time="2026-01-01T00:00:00Z",
status_since="2026-01-01T00:00:00+00:00",
)
result = await monitor.update_all_streams(["raise.com", "ok.com"])
@@ -1049,8 +1060,8 @@ class TestStreamMonitorMetrics:
stream_state=StreamState(
domain="live.com",
title="Title",
last_connect_time="2026-01-01T12:00:00Z",
last_disconnect_time="2026-01-01T10:00:00Z",
online=True,
status_since="2026-01-01T12:00:00+00:00",
),
stream_config=StreamConfig(name="Live Stream"),
)
@@ -1065,7 +1076,7 @@ class TestStreamMonitorMetrics:
stream_repo,
subscription_repo,
domain="live.com",
last_disconnect_time="2026-01-01T10:00:00Z",
status_since="2026-01-01T10:00:00+00:00",
)
monitor.offline_timer_cache["live.com"] = 0
await monitor.update_stream("live.com")
@@ -1082,7 +1093,7 @@ class TestStreamMonitorMetrics:
stream_state=StreamState(
domain="off.com",
title="Title",
last_disconnect_time="2026-01-01T12:00:00Z",
status_since="2026-01-01T12:00:00+00:00",
),
stream_config=StreamConfig(name="Off Stream"),
)
@@ -1098,7 +1109,7 @@ class TestStreamMonitorMetrics:
subscription_repo,
domain="off.com",
title="Title",
last_disconnect_time="2026-01-01T12:00:00Z",
status_since="2026-01-01T12:00:00+00:00",
)
await monitor.update_stream("off.com")
output = generate_metrics_output(metrics)
@@ -1122,7 +1133,7 @@ class TestStreamMonitorMetrics:
stream_repo,
subscription_repo,
domain="fail.com",
last_disconnect_time="2026-01-01T00:00:00Z",
status_since="2026-01-01T00:00:00+00:00",
)
await monitor.update_stream("fail.com")
output = generate_metrics_output(metrics)
@@ -1138,7 +1149,7 @@ class TestStreamMonitorMetrics:
stream_state=StreamState(
domain="recover.com",
title="Title",
last_disconnect_time="2026-01-01T12:00:00Z",
status_since="2026-01-01T12:00:00+00:00",
),
stream_config=StreamConfig(name="Recover"),
)
@@ -1153,7 +1164,7 @@ class TestStreamMonitorMetrics:
stream_repo,
subscription_repo,
domain="recover.com",
last_disconnect_time="2026-01-01T12:00:00Z",
status_since="2026-01-01T12:00:00+00:00",
)
# Simulate prior failures
for _ in range(3):
@@ -1219,7 +1230,8 @@ class TestStreamMonitorMetrics:
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="pop.com",
last_connect_time="2026-01-01T00:00:00Z",
online=True,
status_since="2026-01-01T00:00:00+00:00",
),
stream_config=StreamConfig(name="Popular"),
)
+5 -3
View File
@@ -256,7 +256,7 @@ class TestManagerListings:
StreamState(
domain="offline.example",
name="Offline",
last_disconnect_time="2026-01-01T00:00:00Z",
status_since="2026-01-01T00:00:00+00:00",
)
)
await stream_repo.create("online.example")
@@ -264,7 +264,8 @@ class TestManagerListings:
StreamState(
domain="online.example",
name="Online",
last_connect_time="2026-01-01T00:00:00Z",
online=True,
status_since="2026-01-01T00:00:00+00:00",
)
)
await stream_repo.create("unknown.example")
@@ -272,7 +273,8 @@ class TestManagerListings:
StreamState(
domain="unknown.example",
name="Unknown",
last_connect_time="2026-01-01T00:00:00Z",
online=True,
status_since="2026-01-01T00:00:00+00:00",
)
)
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
+65 -47
View File
@@ -15,6 +15,7 @@
"""Tests for data models."""
from dataclasses import FrozenInstanceError
from datetime import UTC, datetime, timedelta, timezone
import pytest
@@ -34,6 +35,7 @@ from owncastsentry.types import (
SubscriptionError,
UpdateResult,
_truncate,
format_status_since,
)
@@ -58,107 +60,130 @@ class TestStreamStateStatus:
"""Stream status derivation from state fields."""
@pytest.mark.parametrize(
("failure_counter", "last_connect_time", "expected"),
("failure_counter", "online", "expected"),
[
pytest.param(
UNKNOWN_STATUS_THRESHOLD + 1,
None,
False,
StreamStatus.UNKNOWN,
id="above-threshold-offline-returns-unknown",
),
pytest.param(
UNKNOWN_STATUS_THRESHOLD + 1,
"2026-01-01T00:00:00Z",
True,
StreamStatus.UNKNOWN,
id="above-threshold-online-returns-unknown",
),
pytest.param(
0,
"2026-01-01T00:00:00Z",
True,
StreamStatus.ONLINE,
id="zero-failures-with-connect-time-returns-online",
id="zero-failures-online-returns-online",
),
pytest.param(
0,
None,
False,
StreamStatus.OFFLINE,
id="zero-failures-no-connect-time-returns-offline",
id="zero-failures-offline-returns-offline",
),
pytest.param(
UNKNOWN_STATUS_THRESHOLD,
"2026-01-01T00:00:00Z",
True,
StreamStatus.ONLINE,
id="at-threshold-with-connect-time-returns-online",
id="at-threshold-online-returns-online",
),
pytest.param(
UNKNOWN_STATUS_THRESHOLD,
None,
False,
StreamStatus.OFFLINE,
id="at-threshold-no-connect-time-returns-offline",
id="at-threshold-offline-returns-offline",
),
],
)
def test_status(
self,
failure_counter: int,
last_connect_time: str | None,
online: object,
expected: StreamStatus,
) -> None:
"""Return the correct status based on failure counter and connect time."""
"""Return the correct status based on failure counter and online state."""
state = StreamState(
domain="example.com",
failure_counter=failure_counter,
last_connect_time=last_connect_time,
online=online is True,
)
assert state.status is expected
class TestFormatStatusSince:
"""Status timestamp formatting."""
def test_formats_as_utc_iso_seconds(self) -> None:
"""Normalize aware datetimes to UTC with second precision."""
timestamp = datetime(
2026,
1,
1,
12,
34,
56,
123456,
tzinfo=timezone(timedelta(hours=-5)),
)
assert format_status_since(timestamp) == "2026-01-01T17:34:56+00:00"
def test_treats_naive_as_utc(self) -> None:
"""Format naive datetimes as UTC."""
timestamp = datetime.fromisoformat("2026-01-01T12:34:56.123456")
assert format_status_since(timestamp) == "2026-01-01T12:34:56+00:00"
class TestStreamStateFromApiResponse:
"""StreamState construction from an API response dictionary."""
def test_typical_response(self) -> None:
"""Populate API-derived fields from a complete stream state response."""
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
response = {
"streamTitle": "My Stream",
"lastConnectTime": "2026-01-01T00:00:00Z",
"lastDisconnectTime": "2025-12-31T23:00:00Z",
"online": True,
}
state = StreamState.from_api_response(response, "example.com")
state = StreamState.from_api_response(response, "example.com", observed_at)
assert state.domain == "example.com"
assert state.title == "My Stream"
assert state.last_connect_time == "2026-01-01T00:00:00Z"
assert state.last_disconnect_time == "2025-12-31T23:00:00Z"
assert state.online is True
assert state.status_since == "2026-01-01T00:00:01+00:00"
assert state.name is None
assert state.failure_counter == 0
def test_missing_required_field_raises(self) -> None:
"""Reject API responses without required stream state fields."""
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
with pytest.raises(InvalidApiResponseError):
StreamState.from_api_response({}, "bare.example.com")
StreamState.from_api_response({}, "bare.example.com", observed_at)
def test_nullable_timestamp_fields(self) -> None:
"""Accept null values for Owncast timestamp fields."""
def test_offline_response(self) -> None:
"""Populate offline state from a status response."""
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
response = {
"streamTitle": "Offline Stream",
"lastConnectTime": None,
"lastDisconnectTime": None,
"online": False,
}
state = StreamState.from_api_response(response, "example.com")
assert state.last_connect_time is None
assert state.last_disconnect_time is None
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"
def test_title_truncation(self) -> None:
"""Truncate the stream title to _MAX_STREAM_TITLE_LENGTH."""
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
long_title = "A" * (_MAX_STREAM_TITLE_LENGTH + 50)
response = {
"streamTitle": long_title,
"lastConnectTime": None,
"lastDisconnectTime": None,
"online": True,
}
state = StreamState.from_api_response(response, "example.com")
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
@@ -166,27 +191,20 @@ class TestStreamStateFromApiResponse:
("field", "value"),
[
pytest.param("streamTitle", 123, id="title-not-string"),
pytest.param("lastConnectTime", [], id="connect-time-not-string-or-null"),
pytest.param(
"lastDisconnectTime",
{},
id="disconnect-time-not-string-or-null",
),
pytest.param("online", "true", id="online-not-bool"),
],
)
def test_invalid_field_type_raises(self, field: str, value: object) -> None:
"""Reject stream state responses with malformed field types."""
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
response: dict[str, object] = {
"streamTitle": "My Stream",
"lastConnectTime": None,
"lastDisconnectTime": None,
"online": True,
}
response[field] = value
with pytest.raises(InvalidApiResponseError):
StreamState.from_api_response(response, "example.com")
StreamState.from_api_response(response, "example.com", observed_at)
class TestStreamStateFromDbRow:
@@ -198,16 +216,16 @@ class TestStreamStateFromDbRow:
"domain": "example.com",
"name": "Test Instance",
"title": "Live Now",
"last_connect_time": "2026-01-01T00:00:00Z",
"last_disconnect_time": "2025-12-31T23:00:00Z",
"online": True,
"status_since": "2026-01-01T00:00:00+00:00",
"failure_counter": 3,
}
state = StreamState.from_db_row(row)
assert state.domain == "example.com"
assert state.name == "Test Instance"
assert state.title == "Live Now"
assert state.last_connect_time == "2026-01-01T00:00:00Z"
assert state.last_disconnect_time == "2025-12-31T23:00:00Z"
assert state.online is True
assert state.status_since == "2026-01-01T00:00:00+00:00"
assert state.failure_counter == 3
def test_row_with_none_optional_fields(self) -> None:
@@ -216,16 +234,16 @@ class TestStreamStateFromDbRow:
"domain": "example.com",
"name": None,
"title": None,
"last_connect_time": None,
"last_disconnect_time": None,
"online": False,
"status_since": None,
"failure_counter": 0,
}
state = StreamState.from_db_row(row)
assert state.domain == "example.com"
assert state.name is None
assert state.title is None
assert state.last_connect_time is None
assert state.last_disconnect_time is None
assert state.online is False
assert state.status_since is None
assert state.failure_counter == 0