1029 lines
38 KiB
Python
1029 lines
38 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."""
|
|
|
|
import sqlite3
|
|
from contextlib import closing
|
|
from datetime import UTC, datetime
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
from mautrix.util.async_db import Database
|
|
|
|
from owncastsentry.repository import _normalize_legacy_status_since, get_upgrade_table
|
|
from owncastsentry.types import (
|
|
UNKNOWN_STATUS_THRESHOLD,
|
|
AlreadySubscribedError,
|
|
NotSubscribedError,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
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",
|
|
]
|
|
|
|
async def test_stream_constraint_metadata(self, database: Database) -> None:
|
|
"""Declare stream primary key and non-null constrained columns."""
|
|
async with database.acquire() as conn:
|
|
rows = await conn.fetch("PRAGMA table_info(streams)")
|
|
columns = {row["name"]: row for row in rows}
|
|
|
|
assert columns["domain"]["pk"] == 1
|
|
assert columns["domain"]["notnull"] == 1
|
|
assert columns["online"]["notnull"] == 1
|
|
assert columns["failure_counter"]["notnull"] == 1
|
|
|
|
@pytest.mark.parametrize(
|
|
("query", "params"),
|
|
[
|
|
pytest.param(
|
|
"INSERT INTO streams (domain) VALUES ($1)",
|
|
(None,),
|
|
id="null-domain",
|
|
),
|
|
pytest.param(
|
|
"INSERT INTO streams (domain) VALUES ($1)",
|
|
("",),
|
|
id="blank-domain",
|
|
),
|
|
pytest.param(
|
|
"INSERT INTO streams (domain) VALUES ($1)",
|
|
(" ",),
|
|
id="whitespace-domain",
|
|
),
|
|
pytest.param(
|
|
"INSERT INTO streams (domain, online) VALUES ($1, $2)",
|
|
("bad-online.example", 2),
|
|
id="invalid-online",
|
|
),
|
|
pytest.param(
|
|
"""INSERT INTO streams (domain, failure_counter)
|
|
VALUES ($1, $2)""",
|
|
("bad-counter.example", -1),
|
|
id="negative-failure-counter",
|
|
),
|
|
],
|
|
)
|
|
async def test_rejects_invalid_stream_values(
|
|
self,
|
|
database: Database,
|
|
query: str,
|
|
params: tuple[object, ...],
|
|
) -> None:
|
|
"""Reject stream rows that violate current value constraints."""
|
|
async with database.acquire() as conn:
|
|
with pytest.raises(sqlite3.IntegrityError):
|
|
await conn.execute(query, *params)
|
|
|
|
|
|
class TestSubscriptionSchema:
|
|
"""Subscriptions table schema after migrations."""
|
|
|
|
async def test_subscription_constraint_metadata(self, database: Database) -> None:
|
|
"""Declare subscription key, foreign key, index, and lifecycle triggers."""
|
|
async with database.acquire() as conn:
|
|
rows = await conn.fetch("PRAGMA table_info(subscriptions)")
|
|
columns = {row["name"]: row for row in rows}
|
|
foreign_keys = await conn.fetch("PRAGMA foreign_key_list(subscriptions)")
|
|
indexes = await conn.fetch("PRAGMA index_list(subscriptions)")
|
|
index_names = {row["name"] for row in indexes}
|
|
domain_index_columns = await conn.fetch(
|
|
"PRAGMA index_info(subscriptions_stream_domain_idx)"
|
|
)
|
|
triggers = await conn.fetch(
|
|
"SELECT name FROM sqlite_schema WHERE type='trigger'"
|
|
)
|
|
trigger_names = {row["name"] for row in triggers}
|
|
|
|
assert columns["room_id"]["pk"] == 1
|
|
assert columns["stream_domain"]["pk"] == 2
|
|
assert columns["room_id"]["notnull"] == 1
|
|
assert columns["stream_domain"]["notnull"] == 1
|
|
assert "subscriptions_stream_domain_idx" in index_names
|
|
assert [row["name"] for row in domain_index_columns] == ["stream_domain"]
|
|
assert len(foreign_keys) == 1
|
|
assert foreign_keys[0]["table"] == "streams"
|
|
assert foreign_keys[0]["from"] == "stream_domain"
|
|
assert foreign_keys[0]["to"] == "domain"
|
|
assert foreign_keys[0]["on_delete"] == "CASCADE"
|
|
assert "create_stream_for_subscription" in trigger_names
|
|
assert "delete_unsubscribed_stream" in trigger_names
|
|
|
|
@pytest.mark.parametrize(
|
|
("stream_domain", "room_id"),
|
|
[
|
|
pytest.param(None, "!null-domain:example.com", id="null-domain"),
|
|
pytest.param("", "!blank-domain:example.com", id="blank-domain"),
|
|
pytest.param(" ", "!blank-domain:example.com", id="whitespace-domain"),
|
|
pytest.param("example.com", None, id="null-room"),
|
|
pytest.param("example.com", "", id="blank-room"),
|
|
pytest.param("example.com", " ", id="whitespace-room"),
|
|
],
|
|
)
|
|
async def test_rejects_invalid_subscription_values(
|
|
self,
|
|
database: Database,
|
|
stream_domain: str | None,
|
|
room_id: str | None,
|
|
) -> None:
|
|
"""Reject subscription rows that violate current constraints."""
|
|
async with database.acquire() as conn:
|
|
await conn.execute(
|
|
"INSERT INTO streams (domain) VALUES ($1)",
|
|
"example.com",
|
|
)
|
|
|
|
with pytest.raises(sqlite3.IntegrityError):
|
|
await conn.execute(
|
|
"""INSERT INTO subscriptions (stream_domain, room_id)
|
|
VALUES ($1, $2)""",
|
|
stream_domain,
|
|
room_id,
|
|
)
|
|
|
|
async def test_subscription_insert_creates_stream_parent(
|
|
self, database: Database
|
|
) -> None:
|
|
"""Create the stream parent row from the subscription insert trigger."""
|
|
async with database.acquire() as conn:
|
|
await conn.execute(
|
|
"""INSERT INTO subscriptions (stream_domain, room_id)
|
|
VALUES ($1, $2)""",
|
|
"missing.example",
|
|
"!room:example.com",
|
|
)
|
|
stream = await conn.fetchrow(
|
|
"SELECT * FROM streams WHERE domain=$1",
|
|
"missing.example",
|
|
)
|
|
|
|
assert stream is not None
|
|
assert stream["online"] == 0
|
|
assert stream["failure_counter"] == 0
|
|
|
|
async def test_subscription_insert_rolls_back_created_stream_on_failure(
|
|
self, database: Database
|
|
) -> None:
|
|
"""Roll back the trigger-created stream when the subscription is invalid."""
|
|
async with database.acquire() as conn:
|
|
with pytest.raises(sqlite3.IntegrityError):
|
|
await conn.execute(
|
|
"""INSERT INTO subscriptions (stream_domain, room_id)
|
|
VALUES ($1, $2)""",
|
|
"rollback.example",
|
|
"",
|
|
)
|
|
|
|
stream = await conn.fetchrow(
|
|
"SELECT * FROM streams WHERE domain=$1",
|
|
"rollback.example",
|
|
)
|
|
subscription = await conn.fetchrow(
|
|
"""SELECT * FROM subscriptions
|
|
WHERE stream_domain=$1""",
|
|
"rollback.example",
|
|
)
|
|
|
|
assert stream is None
|
|
assert subscription is None
|
|
|
|
async def test_subscription_primary_key_rejects_duplicates(
|
|
self, database: Database
|
|
) -> None:
|
|
"""Reject duplicate room/domain subscription rows."""
|
|
async with database.acquire() as conn:
|
|
await conn.execute(
|
|
"INSERT INTO streams (domain) VALUES ($1)",
|
|
"example.com",
|
|
)
|
|
await conn.execute(
|
|
"""INSERT INTO subscriptions (stream_domain, room_id)
|
|
VALUES ($1, $2)""",
|
|
"example.com",
|
|
"!room:example.com",
|
|
)
|
|
|
|
with pytest.raises(sqlite3.IntegrityError):
|
|
await conn.execute(
|
|
"""INSERT INTO subscriptions (stream_domain, room_id)
|
|
VALUES ($1, $2)""",
|
|
"example.com",
|
|
"!room:example.com",
|
|
)
|
|
|
|
|
|
class TestSubscriptionLifecycleConstraints:
|
|
"""Database-owned cleanup behavior between streams and subscriptions."""
|
|
|
|
async def test_stream_delete_cascades_subscriptions(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Delete dependent subscription rows when a stream is deleted."""
|
|
await stream_repo.create("example.com")
|
|
await subscription_repo.add("example.com", "!room:example.com")
|
|
|
|
await stream_repo.delete("example.com")
|
|
|
|
assert await subscription_repo.get_subscribed_rooms("example.com") == []
|
|
|
|
async def test_last_subscription_delete_removes_stream(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Delete the stream row when its last subscription is removed."""
|
|
await stream_repo.create("example.com")
|
|
await subscription_repo.add("example.com", "!room:example.com")
|
|
|
|
await subscription_repo.remove("example.com", "!room:example.com")
|
|
|
|
assert await stream_repo.get_by_domain("example.com") is None
|
|
|
|
async def test_subscription_delete_keeps_stream_with_remaining_rooms(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Keep stream state while at least one subscription remains."""
|
|
await stream_repo.create("example.com")
|
|
await subscription_repo.add("example.com", "!room1:example.com")
|
|
await subscription_repo.add("example.com", "!room2:example.com")
|
|
|
|
await subscription_repo.remove("example.com", "!room1:example.com")
|
|
|
|
assert await stream_repo.get_by_domain("example.com") is not None
|
|
assert await subscription_repo.get_subscribed_rooms("example.com") == [
|
|
"!room2:example.com"
|
|
]
|
|
|
|
|
|
class TestConstraintMigration:
|
|
"""Migration behavior for legacy data that did not have constraints."""
|
|
|
|
async def test_converts_legacy_status_timestamps_from_v3(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
"""Convert legacy connect/disconnect timestamps during v4 migration."""
|
|
db_path = tmp_path / "legacy.db"
|
|
with closing(sqlite3.connect(db_path)) as conn, conn:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE version (version INTEGER PRIMARY KEY);
|
|
INSERT INTO version (version) VALUES (3);
|
|
|
|
CREATE TABLE streams (
|
|
domain TEXT NOT NULL UNIQUE,
|
|
name TEXT,
|
|
title TEXT,
|
|
last_connect_time TEXT,
|
|
last_disconnect_time TEXT,
|
|
failure_counter INTEGER DEFAULT 0,
|
|
PRIMARY KEY(domain)
|
|
);
|
|
|
|
CREATE TABLE subscriptions (
|
|
stream_domain TEXT NOT NULL,
|
|
room_id TEXT NOT NULL,
|
|
UNIQUE(room_id, stream_domain)
|
|
);
|
|
|
|
INSERT INTO streams (
|
|
domain,
|
|
last_connect_time,
|
|
last_disconnect_time,
|
|
failure_counter
|
|
)
|
|
VALUES
|
|
(
|
|
'live.example',
|
|
'2026-05-21T19:06:24Z',
|
|
'2026-05-20T00:00:00+00:00',
|
|
2
|
|
),
|
|
(
|
|
'offline.example',
|
|
NULL,
|
|
'2026-05-17T21:23:27-04:00',
|
|
3
|
|
),
|
|
(
|
|
'blank-connect.example',
|
|
'',
|
|
'2026-05-20T12:34:56+02:00',
|
|
4
|
|
),
|
|
(
|
|
'invalid-timestamp.example',
|
|
NULL,
|
|
'not a timestamp',
|
|
5
|
|
);
|
|
|
|
INSERT INTO subscriptions (stream_domain, room_id)
|
|
VALUES
|
|
('live.example', '!live:example.com'),
|
|
('offline.example', '!offline:example.com'),
|
|
('blank-connect.example', '!blank:example.com'),
|
|
('invalid-timestamp.example', '!invalid:example.com');
|
|
"""
|
|
)
|
|
|
|
db = Database.create(f"sqlite:///{db_path}", upgrade_table=get_upgrade_table())
|
|
await db.start()
|
|
try:
|
|
async with db.acquire() as conn:
|
|
rows = await conn.fetch(
|
|
"""SELECT domain, online, status_since, failure_counter
|
|
FROM streams
|
|
ORDER BY domain"""
|
|
)
|
|
finally:
|
|
await db.stop()
|
|
|
|
streams = {row["domain"]: row for row in rows}
|
|
assert streams["live.example"]["online"] == 1
|
|
assert streams["live.example"]["status_since"] == "2026-05-21T19:06:24+00:00"
|
|
assert streams["live.example"]["failure_counter"] == 2
|
|
assert streams["offline.example"]["online"] == 0
|
|
assert streams["offline.example"]["status_since"] == "2026-05-18T01:23:27+00:00"
|
|
assert streams["offline.example"]["failure_counter"] == 3
|
|
assert streams["blank-connect.example"]["online"] == 0
|
|
assert (
|
|
streams["blank-connect.example"]["status_since"]
|
|
== "2026-05-20T10:34:56+00:00"
|
|
)
|
|
assert streams["invalid-timestamp.example"]["online"] == 0
|
|
assert streams["invalid-timestamp.example"]["status_since"] is None
|
|
|
|
async def test_preserves_legacy_orphaned_subscription(self, tmp_path: Path) -> None:
|
|
"""Create placeholder streams before enforcing subscription foreign keys."""
|
|
db_path = tmp_path / "legacy.db"
|
|
with closing(sqlite3.connect(db_path)) as conn, conn:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE version (version INTEGER PRIMARY KEY);
|
|
INSERT INTO version (version) VALUES (4);
|
|
|
|
CREATE TABLE streams (
|
|
domain TEXT NOT NULL UNIQUE,
|
|
name TEXT,
|
|
title TEXT,
|
|
online BOOLEAN NOT NULL DEFAULT false,
|
|
status_since TEXT,
|
|
failure_counter INTEGER DEFAULT 0,
|
|
PRIMARY KEY(domain)
|
|
);
|
|
|
|
CREATE TABLE subscriptions (
|
|
stream_domain TEXT NOT NULL,
|
|
room_id TEXT NOT NULL,
|
|
UNIQUE(room_id, stream_domain)
|
|
);
|
|
|
|
INSERT INTO subscriptions (stream_domain, room_id)
|
|
VALUES ('missing.example', '!room:example.com');
|
|
"""
|
|
)
|
|
|
|
db = Database.create(f"sqlite:///{db_path}", upgrade_table=get_upgrade_table())
|
|
await db.start()
|
|
try:
|
|
async with db.acquire() as conn:
|
|
stream = await conn.fetchrow(
|
|
"SELECT * FROM streams WHERE domain=$1",
|
|
"missing.example",
|
|
)
|
|
subscription = await conn.fetchrow(
|
|
"""SELECT * FROM subscriptions
|
|
WHERE stream_domain=$1 AND room_id=$2""",
|
|
"missing.example",
|
|
"!room:example.com",
|
|
)
|
|
finally:
|
|
await db.stop()
|
|
|
|
assert stream is not None
|
|
assert stream["online"] == 0
|
|
assert stream["failure_counter"] == 0
|
|
assert subscription is not None
|
|
|
|
async def test_removes_legacy_stream_without_subscriptions(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
"""Drop stream rows that are no longer referenced by subscriptions."""
|
|
db_path = tmp_path / "legacy.db"
|
|
with closing(sqlite3.connect(db_path)) as conn, conn:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE version (version INTEGER PRIMARY KEY);
|
|
INSERT INTO version (version) VALUES (4);
|
|
|
|
CREATE TABLE streams (
|
|
domain TEXT NOT NULL UNIQUE,
|
|
name TEXT,
|
|
title TEXT,
|
|
online BOOLEAN NOT NULL DEFAULT false,
|
|
status_since TEXT,
|
|
failure_counter INTEGER DEFAULT 0,
|
|
PRIMARY KEY(domain)
|
|
);
|
|
|
|
CREATE TABLE subscriptions (
|
|
stream_domain TEXT NOT NULL,
|
|
room_id TEXT NOT NULL,
|
|
UNIQUE(room_id, stream_domain)
|
|
);
|
|
|
|
INSERT INTO streams (domain)
|
|
VALUES ('subscribed.example'), ('orphaned.example');
|
|
INSERT INTO subscriptions (stream_domain, room_id)
|
|
VALUES ('subscribed.example', '!room:example.com');
|
|
"""
|
|
)
|
|
|
|
db = Database.create(f"sqlite:///{db_path}", upgrade_table=get_upgrade_table())
|
|
await db.start()
|
|
try:
|
|
async with db.acquire() as conn:
|
|
subscribed_stream = await conn.fetchrow(
|
|
"SELECT * FROM streams WHERE domain=$1",
|
|
"subscribed.example",
|
|
)
|
|
orphaned_stream = await conn.fetchrow(
|
|
"SELECT * FROM streams WHERE domain=$1",
|
|
"orphaned.example",
|
|
)
|
|
finally:
|
|
await db.stop()
|
|
|
|
assert subscribed_stream is not None
|
|
assert orphaned_stream is None
|
|
|
|
async def test_preserves_and_coerces_legacy_stream_state(
|
|
self, tmp_path: Path
|
|
) -> None:
|
|
"""Keep subscribed stream state while coercing legacy constrained values."""
|
|
db_path = tmp_path / "legacy.db"
|
|
with closing(sqlite3.connect(db_path)) as conn, conn:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE version (version INTEGER PRIMARY KEY);
|
|
INSERT INTO version (version) VALUES (4);
|
|
|
|
CREATE TABLE streams (
|
|
domain TEXT NOT NULL UNIQUE,
|
|
name TEXT,
|
|
title TEXT,
|
|
online BOOLEAN NOT NULL DEFAULT false,
|
|
status_since TEXT,
|
|
failure_counter INTEGER DEFAULT 0,
|
|
PRIMARY KEY(domain)
|
|
);
|
|
|
|
CREATE TABLE subscriptions (
|
|
stream_domain TEXT NOT NULL,
|
|
room_id TEXT NOT NULL,
|
|
UNIQUE(room_id, stream_domain)
|
|
);
|
|
|
|
INSERT INTO streams (
|
|
domain, name, title, online, status_since, failure_counter
|
|
)
|
|
VALUES
|
|
(
|
|
'preserved.example',
|
|
'Preserved Name',
|
|
'Preserved Title',
|
|
1,
|
|
'2026-01-01T12:00:00+00:00',
|
|
7
|
|
),
|
|
('truthy-online.example', NULL, NULL, 2, NULL, 0),
|
|
('falsy-online.example', NULL, NULL, 0, NULL, 0),
|
|
('negative-counter.example', NULL, NULL, 0, NULL, -3),
|
|
('null-counter.example', NULL, NULL, 0, NULL, NULL);
|
|
|
|
INSERT INTO subscriptions (stream_domain, room_id)
|
|
VALUES
|
|
('preserved.example', '!preserved:example.com'),
|
|
('truthy-online.example', '!truthy:example.com'),
|
|
('falsy-online.example', '!falsy:example.com'),
|
|
('negative-counter.example', '!negative:example.com'),
|
|
('null-counter.example', '!null:example.com');
|
|
"""
|
|
)
|
|
|
|
db = Database.create(f"sqlite:///{db_path}", upgrade_table=get_upgrade_table())
|
|
await db.start()
|
|
try:
|
|
async with db.acquire() as conn:
|
|
rows = await conn.fetch(
|
|
"""SELECT domain, name, title, online, status_since, failure_counter
|
|
FROM streams
|
|
ORDER BY domain"""
|
|
)
|
|
finally:
|
|
await db.stop()
|
|
|
|
streams = {row["domain"]: row for row in rows}
|
|
preserved = streams["preserved.example"]
|
|
assert preserved["name"] == "Preserved Name"
|
|
assert preserved["title"] == "Preserved Title"
|
|
assert preserved["online"] == 1
|
|
assert preserved["status_since"] == "2026-01-01T12:00:00+00:00"
|
|
assert preserved["failure_counter"] == 7
|
|
assert streams["truthy-online.example"]["online"] == 1
|
|
assert streams["falsy-online.example"]["online"] == 0
|
|
assert streams["negative-counter.example"]["failure_counter"] == 0
|
|
assert streams["null-counter.example"]["failure_counter"] == 0
|
|
|
|
async def test_removes_invalid_legacy_subscriptions(self, tmp_path: Path) -> None:
|
|
"""Drop legacy subscriptions with unusable domains or room IDs."""
|
|
db_path = tmp_path / "legacy.db"
|
|
with closing(sqlite3.connect(db_path)) as conn, conn:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE version (version INTEGER PRIMARY KEY);
|
|
INSERT INTO version (version) VALUES (4);
|
|
|
|
CREATE TABLE streams (
|
|
domain TEXT NOT NULL UNIQUE,
|
|
name TEXT,
|
|
title TEXT,
|
|
online BOOLEAN NOT NULL DEFAULT false,
|
|
status_since TEXT,
|
|
failure_counter INTEGER DEFAULT 0,
|
|
PRIMARY KEY(domain)
|
|
);
|
|
|
|
CREATE TABLE subscriptions (
|
|
stream_domain TEXT,
|
|
room_id TEXT,
|
|
UNIQUE(room_id, stream_domain)
|
|
);
|
|
|
|
INSERT INTO streams (domain)
|
|
VALUES ('valid.example'), ('invalid-room.example');
|
|
INSERT INTO subscriptions (stream_domain, room_id)
|
|
VALUES
|
|
('valid.example', '!room:example.com'),
|
|
('invalid-room.example', ''),
|
|
('', '!blank-domain:example.com'),
|
|
(NULL, '!null-domain:example.com'),
|
|
('null-room.example', NULL);
|
|
"""
|
|
)
|
|
|
|
db = Database.create(f"sqlite:///{db_path}", upgrade_table=get_upgrade_table())
|
|
await db.start()
|
|
try:
|
|
async with db.acquire() as conn:
|
|
streams = await conn.fetch("SELECT domain FROM streams")
|
|
subscriptions = await conn.fetch(
|
|
"SELECT stream_domain, room_id FROM subscriptions"
|
|
)
|
|
finally:
|
|
await db.stop()
|
|
|
|
assert [row["domain"] for row in streams] == ["valid.example"]
|
|
assert [(row["stream_domain"], row["room_id"]) for row in subscriptions] == [
|
|
("valid.example", "!room:example.com")
|
|
]
|
|
|
|
|
|
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(
|
|
datetime(2026, 5, 21, 19, 6, 24, tzinfo=UTC),
|
|
"2026-05-21T19:06:24+00:00",
|
|
id="aware-datetime",
|
|
),
|
|
pytest.param(
|
|
datetime(2026, 5, 21, 19, 6, 24, tzinfo=UTC).replace(tzinfo=None),
|
|
None,
|
|
id="naive-datetime",
|
|
),
|
|
pytest.param("", None, id="blank"),
|
|
pytest.param(None, None, id="null"),
|
|
pytest.param("not a timestamp", None, id="malformed"),
|
|
pytest.param(123, None, id="unsupported-type"),
|
|
],
|
|
)
|
|
def test_normalizes_parseable_aware_timestamps(
|
|
self, value: object, expected: str | None
|
|
) -> None:
|
|
"""Normalize parseable legacy values and ignore unusable ones."""
|
|
assert _normalize_legacy_status_since(value) == expected
|
|
|
|
|
|
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 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 TestStreamFailureCounter:
|
|
"""Stream failure counter updates."""
|
|
|
|
async def test_reset_failure_counter_sets_counter_to_zero(
|
|
self, stream_repo: StreamRepository
|
|
) -> None:
|
|
"""Reset a nonzero failure counter back to zero."""
|
|
await stream_repo.create("example.com")
|
|
await stream_repo.increment_failure_counter("example.com")
|
|
await stream_repo.increment_failure_counter("example.com")
|
|
|
|
await stream_repo.reset_failure_counter("example.com")
|
|
|
|
state = await stream_repo.get_by_domain("example.com")
|
|
assert state is not None
|
|
assert state.failure_counter == 0
|
|
|
|
|
|
class TestHasRoomSubscriptions:
|
|
"""Room subscription existence checks."""
|
|
|
|
async def test_returns_true_for_subscribed_room(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Return True when the room has at least one subscription."""
|
|
await stream_repo.create("alpha.com")
|
|
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(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Return sorted resolved subscriptions."""
|
|
await stream_repo.create("beta.example")
|
|
await stream_repo.update("beta.example", name="Beta")
|
|
await stream_repo.create("alpha.example")
|
|
await stream_repo.update("alpha.example", name="Alpha")
|
|
await subscription_repo.add("beta.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(
|
|
"offline.example",
|
|
name="Offline",
|
|
status_since="2026-01-01T00:00:00+00:00",
|
|
)
|
|
await stream_repo.create("online.example")
|
|
await stream_repo.update(
|
|
"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(
|
|
"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_creates_stream_parent(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Create the stream row when adding a subscription."""
|
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
|
|
assert await stream_repo.get_by_domain("alpha.com") is not None
|
|
|
|
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 TestHasDomainSubscriptions:
|
|
"""Domain subscription existence checks."""
|
|
|
|
async def test_returns_true_for_subscribed_domain(
|
|
self,
|
|
stream_repo: StreamRepository,
|
|
subscription_repo: SubscriptionRepository,
|
|
) -> None:
|
|
"""Return True when the domain has at least one subscription."""
|
|
await stream_repo.create("alpha.com")
|
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
|
|
assert await subscription_repo.has_domain_subscriptions("alpha.com")
|
|
|
|
async def test_returns_false_for_unknown_domain(
|
|
self, subscription_repo: SubscriptionRepository
|
|
) -> None:
|
|
"""Return False when the domain has no subscriptions."""
|
|
assert not await subscription_repo.has_domain_subscriptions("unknown.com")
|
|
|
|
|
|
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([]) == {}
|