427 lines
15 KiB
Python
427 lines
15 KiB
Python
# Copyright 2026 Logan Fick
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Tests for database repository classes."""
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from owncastsentry.repository import _normalize_legacy_status_since
|
|
from owncastsentry.types import (
|
|
UNKNOWN_STATUS_THRESHOLD,
|
|
AlreadySubscribedError,
|
|
NotSubscribedError,
|
|
StreamState,
|
|
)
|
|
|
|
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."""
|
|
|
|
async def test_returns_true_for_existing_stream(
|
|
self, stream_repo: StreamRepository
|
|
) -> None:
|
|
"""Return True when the stream exists in the database."""
|
|
await stream_repo.create("example.com")
|
|
assert await stream_repo.exists("example.com") is True
|
|
|
|
async def test_returns_false_for_missing_stream(
|
|
self, stream_repo: StreamRepository
|
|
) -> None:
|
|
"""Return False when the stream does not exist in the database."""
|
|
assert await stream_repo.exists("missing.com") is False
|
|
|
|
|
|
class TestStreamCreate:
|
|
"""Stream creation behavior."""
|
|
|
|
async def test_returns_true_when_created(
|
|
self, stream_repo: StreamRepository
|
|
) -> None:
|
|
"""Return True when a stream row is inserted."""
|
|
assert await stream_repo.create("example.com") is True
|
|
|
|
async def test_returns_false_when_existing(
|
|
self, stream_repo: StreamRepository
|
|
) -> None:
|
|
"""Return False when a stream row already exists."""
|
|
await stream_repo.create("example.com")
|
|
assert await stream_repo.create("example.com") is False
|
|
|
|
|
|
class TestStreamDelete:
|
|
"""Stream record deletion."""
|
|
|
|
async def test_removes_stream_record(self, stream_repo: StreamRepository) -> None:
|
|
"""Remove the stream record so get_by_domain returns None."""
|
|
await stream_repo.create("example.com")
|
|
await stream_repo.delete("example.com")
|
|
assert await stream_repo.get_by_domain("example.com") is None
|
|
|
|
|
|
class TestGetSubscribedStreamsForRoom:
|
|
"""Subscribed stream lookup by room."""
|
|
|
|
async def test_returns_all_domains_for_room(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Return all domains a room is subscribed to."""
|
|
await stream_repo.create("alpha.com")
|
|
await stream_repo.create("beta.com")
|
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
await subscription_repo.add("beta.com", "!room1:example.com")
|
|
|
|
result = await subscription_repo.get_subscribed_streams_for_room(
|
|
"!room1:example.com"
|
|
)
|
|
assert sorted(result) == ["alpha.com", "beta.com"]
|
|
|
|
async def test_returns_empty_list_for_unsubscribed_room(
|
|
self, subscription_repo: SubscriptionRepository
|
|
) -> None:
|
|
"""Return an empty list when the room has no subscriptions."""
|
|
result = await subscription_repo.get_subscribed_streams_for_room(
|
|
"!nobody:example.com"
|
|
)
|
|
assert result == []
|
|
|
|
|
|
class TestHasRoomSubscriptions:
|
|
"""Room subscription existence checks."""
|
|
|
|
async def test_returns_true_for_subscribed_room(
|
|
self, subscription_repo: SubscriptionRepository
|
|
) -> None:
|
|
"""Return True when the room has at least one subscription."""
|
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
|
|
assert await subscription_repo.has_room_subscriptions("!room1:example.com")
|
|
|
|
async def test_returns_false_for_unsubscribed_room(
|
|
self, subscription_repo: SubscriptionRepository
|
|
) -> None:
|
|
"""Return False when the room has no subscriptions."""
|
|
assert not await subscription_repo.has_room_subscriptions("!nobody:example.com")
|
|
|
|
|
|
class TestGetRoomSubscriptions:
|
|
"""Resolved room subscription lookup."""
|
|
|
|
async def test_returns_sorted_stream_states_and_skips_missing_rows(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Return sorted resolved subscriptions and skip missing stream rows."""
|
|
await stream_repo.create("beta.example")
|
|
await stream_repo.update(StreamState(domain="beta.example", name="Beta"))
|
|
await stream_repo.create("alpha.example")
|
|
await stream_repo.update(StreamState(domain="alpha.example", name="Alpha"))
|
|
await subscription_repo.add("beta.example", "!room:example.com")
|
|
await subscription_repo.add("missing.example", "!room:example.com")
|
|
await subscription_repo.add("alpha.example", "!room:example.com")
|
|
|
|
subscriptions = await subscription_repo.get_room_subscriptions(
|
|
"!room:example.com"
|
|
)
|
|
|
|
assert [subscription.domain for subscription in subscriptions] == [
|
|
"alpha.example",
|
|
"beta.example",
|
|
]
|
|
assert [subscription.stream_state.name for subscription in subscriptions] == [
|
|
"Alpha",
|
|
"Beta",
|
|
]
|
|
|
|
async def test_returns_empty_list_for_unsubscribed_room(
|
|
self, subscription_repo: SubscriptionRepository
|
|
) -> None:
|
|
"""Return an empty list when the room has no resolved subscriptions."""
|
|
subscriptions = await subscription_repo.get_room_subscriptions(
|
|
"!nobody:example.com"
|
|
)
|
|
assert subscriptions == []
|
|
|
|
|
|
class TestGetLiveRoomSubscriptions:
|
|
"""Resolved live room subscription lookup."""
|
|
|
|
async def test_returns_online_streams_and_skips_inactive_states(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Return only streams whose derived status is ONLINE."""
|
|
await stream_repo.create("offline.example")
|
|
await stream_repo.update(
|
|
StreamState(
|
|
domain="offline.example",
|
|
name="Offline",
|
|
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",
|
|
)
|
|
)
|
|
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",
|
|
)
|
|
)
|
|
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
|
|
await stream_repo.increment_failure_counter("unknown.example")
|
|
|
|
await subscription_repo.add("offline.example", "!room:example.com")
|
|
await subscription_repo.add("online.example", "!room:example.com")
|
|
await subscription_repo.add("unknown.example", "!room:example.com")
|
|
|
|
subscriptions = await subscription_repo.get_live_room_subscriptions(
|
|
"!room:example.com"
|
|
)
|
|
|
|
assert [subscription.domain for subscription in subscriptions] == [
|
|
"online.example"
|
|
]
|
|
|
|
async def test_returns_empty_list_for_room_with_no_live_streams(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Return an empty list when no subscribed streams are live."""
|
|
await stream_repo.create("offline.example")
|
|
await subscription_repo.add("offline.example", "!room:example.com")
|
|
|
|
subscriptions = await subscription_repo.get_live_room_subscriptions(
|
|
"!room:example.com"
|
|
)
|
|
|
|
assert subscriptions == []
|
|
|
|
|
|
class TestAddSubscription:
|
|
"""Subscription creation behavior."""
|
|
|
|
async def test_adds_subscription(
|
|
self, subscription_repo: SubscriptionRepository
|
|
) -> None:
|
|
"""Add a subscription row."""
|
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
assert await subscription_repo.get_subscribed_rooms("alpha.com") == [
|
|
"!room1:example.com"
|
|
]
|
|
|
|
async def test_raises_when_existing(
|
|
self, subscription_repo: SubscriptionRepository
|
|
) -> None:
|
|
"""Raise AlreadySubscribedError when a subscription already exists."""
|
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
with pytest.raises(AlreadySubscribedError):
|
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
|
|
|
|
class TestRemoveSubscription:
|
|
"""Subscription removal behavior."""
|
|
|
|
async def test_removes_subscription(
|
|
self, subscription_repo: SubscriptionRepository
|
|
) -> None:
|
|
"""Remove an existing subscription row."""
|
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
await subscription_repo.remove("alpha.com", "!room1:example.com")
|
|
assert await subscription_repo.get_subscribed_rooms("alpha.com") == []
|
|
|
|
async def test_raises_when_missing(
|
|
self, subscription_repo: SubscriptionRepository
|
|
) -> None:
|
|
"""Raise NotSubscribedError when no subscription exists."""
|
|
with pytest.raises(NotSubscribedError):
|
|
await subscription_repo.remove("alpha.com", "!room1:example.com")
|
|
|
|
|
|
class TestGetAllSubscribedDomains:
|
|
"""Unique subscribed domain retrieval."""
|
|
|
|
async def test_returns_each_domain_once(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Return each domain once even with multiple subscriptions."""
|
|
await stream_repo.create("alpha.com")
|
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
await subscription_repo.add("alpha.com", "!room2:example.com")
|
|
|
|
result = await subscription_repo.get_all_subscribed_domains()
|
|
assert result == ["alpha.com"]
|
|
|
|
async def test_returns_empty_list_with_no_subscriptions(
|
|
self, subscription_repo: SubscriptionRepository
|
|
) -> None:
|
|
"""Return an empty list when there are no subscriptions."""
|
|
result = await subscription_repo.get_all_subscribed_domains()
|
|
assert result == []
|
|
|
|
|
|
class TestCountByDomain:
|
|
"""Subscription count by domain."""
|
|
|
|
async def test_returns_correct_count(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Return the correct subscription count for a domain."""
|
|
await stream_repo.create("alpha.com")
|
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
await subscription_repo.add("alpha.com", "!room2:example.com")
|
|
|
|
assert await subscription_repo.count_by_domain("alpha.com") == 2
|
|
|
|
async def test_returns_zero_for_unknown_domain(
|
|
self, subscription_repo: SubscriptionRepository
|
|
) -> None:
|
|
"""Return 0 for a domain with no subscriptions."""
|
|
assert await subscription_repo.count_by_domain("unknown.com") == 0
|
|
|
|
|
|
class TestCountByDomains:
|
|
"""Bulk subscription counts by domain."""
|
|
|
|
async def test_returns_counts_for_requested_domains(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Return counts for requested domains and zero for missing domains."""
|
|
await stream_repo.create("alpha.com")
|
|
await stream_repo.create("beta.com")
|
|
await stream_repo.create("ignored.com")
|
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
await subscription_repo.add("alpha.com", "!room2:example.com")
|
|
await subscription_repo.add("beta.com", "!room3:example.com")
|
|
await subscription_repo.add("ignored.com", "!room4:example.com")
|
|
|
|
assert await subscription_repo.count_by_domains(
|
|
["beta.com", "missing.com", "alpha.com"]
|
|
) == {
|
|
"beta.com": 1,
|
|
"missing.com": 0,
|
|
"alpha.com": 2,
|
|
}
|
|
|
|
async def test_returns_empty_dict_for_empty_domain_list(
|
|
self, subscription_repo: SubscriptionRepository
|
|
) -> None:
|
|
"""Return an empty mapping when no domains are requested."""
|
|
assert await subscription_repo.count_by_domains([]) == {}
|
|
|
|
|
|
class TestDeleteAllForDomain:
|
|
"""Bulk subscription deletion by domain."""
|
|
|
|
async def test_deletes_all_subscriptions_and_returns_count(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Delete all subscriptions for the domain and return the count."""
|
|
await stream_repo.create("alpha.com")
|
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
await subscription_repo.add("alpha.com", "!room2:example.com")
|
|
|
|
deleted = await subscription_repo.delete_all_for_domain("alpha.com")
|
|
assert deleted == 2
|
|
|
|
rooms = await subscription_repo.get_subscribed_rooms("alpha.com")
|
|
assert rooms == []
|
|
|
|
async def test_returns_zero_for_unknown_domain(
|
|
self, subscription_repo: SubscriptionRepository
|
|
) -> None:
|
|
"""Return 0 when deleting subscriptions for an unknown domain."""
|
|
assert await subscription_repo.delete_all_for_domain("unknown.com") == 0
|