Added comprehensive test suite with CI workflow.
Audit / Dependencies (push) Successful in 8s
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 6s
Audit / Dependencies (push) Successful in 8s
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 6s
This commit is contained in:
@@ -43,6 +43,28 @@ jobs:
|
||||
- name: Check linting with Ruff
|
||||
run: uv run ruff check .
|
||||
|
||||
tests:
|
||||
name: Tests
|
||||
runs-on: logaldeveloper-archlinux
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Cache uv packages
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: uv-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run unit tests with Pytest
|
||||
run: uv run pytest -v --cov --cov-report=
|
||||
|
||||
- name: Report code coverage
|
||||
run: uv run coverage report
|
||||
|
||||
type-checking:
|
||||
name: Type Checking
|
||||
runs-on: logaldeveloper-archlinux
|
||||
|
||||
@@ -3,3 +3,4 @@ __pycache__/
|
||||
*$py.class
|
||||
.venv/
|
||||
owncastsentry/_version.py
|
||||
.coverage
|
||||
|
||||
+24
-3
@@ -7,9 +7,7 @@ authors = [
|
||||
]
|
||||
license = "Apache-2.0"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"maubot[encryption]",
|
||||
]
|
||||
dependencies = []
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://git.logal.dev/LogalDeveloper/OwncastSentry"
|
||||
@@ -20,11 +18,17 @@ build-backend = "hatchling.build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"maubot[encryption]>=0.6.0",
|
||||
"aioresponses>=0.7.8",
|
||||
"codespell>=2.4.2",
|
||||
"hatch>=1.16.5",
|
||||
"mypy>=1.19.1",
|
||||
"pip-audit>=2.10.0",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
"ruff>=0.15.5",
|
||||
"time-machine>=3.2.0",
|
||||
]
|
||||
|
||||
[tool.hatch.version]
|
||||
@@ -84,6 +88,9 @@ select = [
|
||||
"PGH", # pygrep-hooks
|
||||
"TC", # flake8-type-checking
|
||||
|
||||
# Testing
|
||||
"PT", # flake8-pytest-style
|
||||
|
||||
# Ruff-specific
|
||||
"RUF", # Ruff-specific rules
|
||||
]
|
||||
@@ -92,5 +99,19 @@ ignore = [
|
||||
"D213", # incompatible with D212 (summary on first line)
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["S101", "S311"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["owncastsentry"]
|
||||
omit = ["owncastsentry/_version.py"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
skip_empty = true
|
||||
|
||||
[tool.codespell]
|
||||
skip = "uv.lock"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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.
|
||||
|
||||
"""OwncastSentry test suite."""
|
||||
@@ -0,0 +1,218 @@
|
||||
# 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.
|
||||
|
||||
"""Shared test fixtures and stubs for OwncastSentry tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
from owncastsentry import OwncastSentry
|
||||
from owncastsentry.config import Config
|
||||
from owncastsentry.database import StreamRepository, SubscriptionRepository
|
||||
from owncastsentry.migrations import get_upgrade_table
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
from owncastsentry.models import StreamConfig, StreamState
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def database(tmp_path: Path) -> AsyncIterator[Database]:
|
||||
"""Yield a real SQLite-backed mautrix Database with migrations applied."""
|
||||
db_path = tmp_path / "test.db"
|
||||
db = Database.create(
|
||||
f"sqlite:///{db_path}",
|
||||
upgrade_table=get_upgrade_table(),
|
||||
)
|
||||
await db.start()
|
||||
yield db
|
||||
await db.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stream_repo(database: Database) -> StreamRepository:
|
||||
"""Return a StreamRepository backed by the test database."""
|
||||
return StreamRepository(database)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def subscription_repo(database: Database) -> SubscriptionRepository:
|
||||
"""Return a SubscriptionRepository backed by the test database."""
|
||||
return SubscriptionRepository(database)
|
||||
|
||||
|
||||
# These fixtures use names expected by the maubot.testing framework.
|
||||
# They look unused but are discovered automatically by maubot's pytest plugin.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def maubot_plugin_class():
|
||||
"""Use OwncastSentry as the plugin class for maubot integration tests."""
|
||||
return OwncastSentry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def maubot_plugin_config_class():
|
||||
"""Use our Config class for maubot integration tests."""
|
||||
return Config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def maubot_upgrade_table():
|
||||
"""Provide the database upgrade table for maubot integration tests."""
|
||||
return get_upgrade_table()
|
||||
|
||||
|
||||
VALID_CONFIG_RESPONSE: dict[str, object] = {
|
||||
"appearanceVariables": {},
|
||||
"name": "LogalDeveloper's Live Stream",
|
||||
"customStyles": "",
|
||||
"streamTitle": "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!", # noqa: E501
|
||||
"offlineMessage": "<p>HTTP/1.1 204 No Content</p>\n<p>You've reached the right place, but I'm not live right now.</p>", # noqa: E501
|
||||
"logo": "/logo",
|
||||
"version": "Owncast v0.2.4-linux-64bit (8e89391309dd3aa4d0db4361a2ba1e144f42f8c8)",
|
||||
"extraPageContent": (
|
||||
"<h1>About</h1>\n<p>Hi there! I'm Logan, a cybersecurity professional "
|
||||
"with a background in Linux systems and network administration. This is my "
|
||||
"little corner of the internet where I exclusively run my live streams. While "
|
||||
"gaming is my most common stream topic, I occasionally stream other things "
|
||||
"which interest me, such as cybersecurity challenges. If you are looking for "
|
||||
"my main website, where my blog and project directory is hosted, please visit: "
|
||||
'<a href="https://logal.dev/">https://logal.dev/</a></p>\n<p>Any views and '
|
||||
"opinions expressed in my live streams and videos are my own and do not "
|
||||
"necessarily reflect those of my employer or any affiliated organizations."
|
||||
"</p>\n<h1>Following</h1>\n<p>I don\u2019t stick to a strict streaming "
|
||||
"schedule, but there are a few ways to stay updated so you know when I go "
|
||||
"live:</p>\n<ul>\n<li>\u2b50 <strong>Matrix</strong>: Send the message "
|
||||
"<code>!subscribe stream.logal.dev</code> to "
|
||||
'<a href="https://matrix.to/#/@owncastsentry:logal.dev">'
|
||||
"@owncastsentry:logal.dev</a>.</li>\n<li><strong>Fediverse</strong>: Follow "
|
||||
"<code>@notify@stream.logal.dev</code>.</li>\n</ul>\n<h1>Chat Commands</h1>"
|
||||
"\n<p>To make things more interactive, there are several commands you can "
|
||||
"send in chat to play sound effects live on stream:</p>\n<ul>\n"
|
||||
"<li>!boom</li>\n<li>!bluetooth</li>\n<li>!bruh</li>\n<li>!creeper</li>\n"
|
||||
"<li>!directed</li>\n<li>!fart</li>\n<li>!icetea</li>\n<li>!oof</li>\n"
|
||||
"<li>!perfect</li>\n<li>!spare</li>\n<li>!thatsit</li>\n<li>!usb</li>\n"
|
||||
"<li>!whocares</li>\n<li>!yoda</li>\n</ul>"
|
||||
),
|
||||
"summary": "Video games, cybersecurity, and more!",
|
||||
"tags": [
|
||||
"video games",
|
||||
"chatting",
|
||||
"casual",
|
||||
"english",
|
||||
"streaming",
|
||||
"owncast",
|
||||
"variety",
|
||||
],
|
||||
"socialHandles": None,
|
||||
"externalActions": [
|
||||
{
|
||||
"url": "https://ko-fi.com/logaldeveloper",
|
||||
"html": "",
|
||||
"title": "Tip",
|
||||
"description": "",
|
||||
"icon": "https://stream.logal.dev/img/platformlogos/ko-fi.svg",
|
||||
"color": "",
|
||||
"openExternally": True,
|
||||
},
|
||||
{
|
||||
"url": "https://tubefree.org/c/logaldeveloper_stream_archive",
|
||||
"html": "",
|
||||
"title": "Previous Live Stream Recordings",
|
||||
"description": "",
|
||||
"icon": "https://stream.logal.dev/img/platformlogos/fediverse.svg",
|
||||
"color": "",
|
||||
"openExternally": True,
|
||||
},
|
||||
],
|
||||
"notifications": {
|
||||
"browser": {
|
||||
"publicKey": "BI9BhIY6c7nfP6ZSIu7T53Lta5sGYqDqwSpiCabZ0XQxPQOmnUHRSjInaa3HX9XmYE-bV8SFmbFk4stZy2jnJ3M", # noqa: E501
|
||||
"enabled": True,
|
||||
},
|
||||
},
|
||||
"federation": {
|
||||
"account": "notify@stream.logal.dev",
|
||||
"followerCount": 74,
|
||||
"enabled": True,
|
||||
},
|
||||
"maxSocketPayloadSize": 2048,
|
||||
"hideViewerCount": False,
|
||||
"chatDisabled": False,
|
||||
"chatSpamProtectionDisabled": False,
|
||||
"nsfw": False,
|
||||
"authentication": {"indieAuthEnabled": True},
|
||||
}
|
||||
|
||||
VALID_STATUS_RESPONSE: dict[str, object] = {
|
||||
"serverTime": "2026-03-13T15:16:14.354962696-04:00",
|
||||
"lastConnectTime": None,
|
||||
"lastDisconnectTime": "2026-03-04T21:05:32-05:00",
|
||||
"versionNumber": "0.2.4",
|
||||
"streamTitle": "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!", # noqa: E501
|
||||
"online": False,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SentMessage:
|
||||
"""A message recorded by _StubMatrixClient."""
|
||||
|
||||
room_id: str
|
||||
content: Any
|
||||
|
||||
|
||||
class _StubMatrixClient:
|
||||
"""Recording stub for the Matrix client used in notification tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent_messages: list[_SentMessage] = []
|
||||
self.should_fail_for_rooms: set[str] = set()
|
||||
|
||||
async def send_message(self, room_id: str, content: Any) -> None:
|
||||
"""Record a sent message, or raise if room is in the fail set."""
|
||||
if room_id in self.should_fail_for_rooms:
|
||||
msg = f"Stubbed failure for room {room_id}"
|
||||
raise RuntimeError(msg)
|
||||
self.sent_messages.append(_SentMessage(room_id=room_id, content=content))
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StubOwncastClient:
|
||||
"""Configurable stub for the Owncast HTTP client."""
|
||||
|
||||
stream_state: StreamState | None = None
|
||||
stream_config: StreamConfig | 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."""
|
||||
self.state_call_count += 1
|
||||
self.queried_domains.append(domain)
|
||||
return self.stream_state
|
||||
|
||||
async def get_stream_config(self, domain: str) -> StreamConfig | None:
|
||||
"""Return the configured stream config."""
|
||||
self.config_call_count += 1
|
||||
return self.stream_config
|
||||
@@ -0,0 +1,455 @@
|
||||
# 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 bot command handlers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
from aioresponses import aioresponses
|
||||
|
||||
from owncastsentry.commands import CommandHandler
|
||||
from owncastsentry.models import StreamState
|
||||
from owncastsentry.utils import OWNCAST_STATUS_PATH
|
||||
from tests.conftest import VALID_STATUS_RESPONSE
|
||||
|
||||
|
||||
def _make_command_handler() -> CommandHandler:
|
||||
"""Build a CommandHandler with dummy dependencies for pure logic tests."""
|
||||
return CommandHandler(
|
||||
owncast_client=MagicMock(),
|
||||
stream_repo=MagicMock(),
|
||||
subscription_repo=MagicMock(),
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
|
||||
class TestFormatDuration:
|
||||
"""Elapsed time calculation from ISO timestamps."""
|
||||
|
||||
_NOW = datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("seconds_ago", "expected"),
|
||||
[
|
||||
pytest.param(1, "1 second", id="singular-second"),
|
||||
pytest.param(30, "30 seconds", id="plural-seconds"),
|
||||
pytest.param(60, "1 minute", id="singular-minute"),
|
||||
pytest.param(120, "2 minutes", id="plural-minutes"),
|
||||
pytest.param(3600, "1 hour", id="singular-hour"),
|
||||
pytest.param(7200, "2 hours", id="plural-hours"),
|
||||
pytest.param(86400, "1 day", id="singular-day"),
|
||||
pytest.param(172800, "2 days", id="plural-days"),
|
||||
],
|
||||
)
|
||||
@time_machine.travel(_NOW)
|
||||
def test_formats_duration(self, seconds_ago: int, expected: str) -> None:
|
||||
"""Format a timestamp into a human-readable duration."""
|
||||
handler = _make_command_handler()
|
||||
timestamp = (self._NOW - timedelta(seconds=seconds_ago)).isoformat()
|
||||
result = handler._format_duration(timestamp)
|
||||
assert result == expected
|
||||
|
||||
def test_invalid_timestamp(self) -> None:
|
||||
"""Return 'unknown duration' for unparsable timestamps."""
|
||||
handler = _make_command_handler()
|
||||
assert handler._format_duration("not-a-timestamp") == "unknown duration"
|
||||
|
||||
|
||||
class TestSubscribeCommand:
|
||||
"""Subscribe command end-to-end via maubot."""
|
||||
|
||||
async def test_subscribe_valid_stream(self, maubot_test_bot, maubot_plugin) -> None:
|
||||
"""Subscribe to a valid Owncast stream."""
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 1
|
||||
assert maubot_test_bot.responded[0].content.body == (
|
||||
"Subscription added! This room will receive notifications when "
|
||||
"stream.logal.dev goes live."
|
||||
)
|
||||
|
||||
async def test_subscribe_invalid_stream(
|
||||
self, maubot_test_bot, maubot_plugin
|
||||
) -> None:
|
||||
"""Reject subscription to an invalid Owncast instance."""
|
||||
status_url = f"https://invalid.com{OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(status_url, status=404)
|
||||
await maubot_test_bot.send("!subscribe invalid.com")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 1
|
||||
assert maubot_test_bot.responded[0].content.body == (
|
||||
"The URL you supplied does not appear to "
|
||||
"be a valid Owncast instance. You may have "
|
||||
"specified an invalid domain, or the "
|
||||
"instance is offline."
|
||||
)
|
||||
|
||||
async def test_subscribe_already_subscribed(
|
||||
self, maubot_test_bot, maubot_plugin
|
||||
) -> None:
|
||||
"""Reject duplicate subscription in the same room."""
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
# Second subscribe; stream already exists so validation is skipped
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"This room is already subscribed to notifications for stream.logal.dev."
|
||||
)
|
||||
|
||||
async def test_subscribe_existing_stream_new_room(
|
||||
self, maubot_test_bot, maubot_plugin
|
||||
) -> None:
|
||||
"""Skip instance validation when subscribing from a new room."""
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
# Subscribe from a different room; skips validation and should not
|
||||
# query the remote instance. An empty aioresponses context will raise
|
||||
# ConnectionError if any HTTP request is attempted.
|
||||
with aioresponses():
|
||||
await maubot_test_bot.send(
|
||||
"!subscribe stream.logal.dev", room_id="!otherroom:example.com"
|
||||
)
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"Subscription added! This room will receive notifications when "
|
||||
"stream.logal.dev goes live."
|
||||
)
|
||||
|
||||
|
||||
class TestUnsubscribeCommand:
|
||||
"""Unsubscribe command end-to-end via maubot."""
|
||||
|
||||
async def test_unsubscribe_existing(self, maubot_test_bot, maubot_plugin) -> None:
|
||||
"""Unsubscribe from a subscribed stream."""
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
await maubot_test_bot.send("!unsubscribe stream.logal.dev")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"Subscription removed! This room will no "
|
||||
"longer receive notifications for stream.logal.dev."
|
||||
)
|
||||
|
||||
async def test_unsubscribe_not_subscribed(
|
||||
self, maubot_test_bot, maubot_plugin
|
||||
) -> None:
|
||||
"""Reject unsubscribe from a non-subscribed stream."""
|
||||
await maubot_test_bot.send("!unsubscribe unknown.com")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 1
|
||||
assert maubot_test_bot.responded[0].content.body == (
|
||||
"This room is already not subscribed to notifications for unknown.com."
|
||||
)
|
||||
|
||||
|
||||
class TestSubscriptionsCommand:
|
||||
"""Subscriptions listing command end-to-end via maubot."""
|
||||
|
||||
async def test_no_subscriptions(self, maubot_test_bot, maubot_plugin) -> None:
|
||||
"""Show help text when no subscriptions exist."""
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 1
|
||||
assert maubot_test_bot.responded[0].content.body == (
|
||||
"This room is not subscribed to any Owncast "
|
||||
"instances.\n\nTo subscribe to an Owncast "
|
||||
"instance, use `!subscribe <domain>`"
|
||||
)
|
||||
|
||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||
async def test_shows_online_stream(self, maubot_test_bot, maubot_plugin) -> None:
|
||||
"""Show stream details including title and duration."""
|
||||
# Subscribe first
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
# 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",
|
||||
last_connect_time="2026-01-01T12:00:00Z",
|
||||
)
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"**Subscriptions for this room (1):**\n\n"
|
||||
"● **Test Stream** \n"
|
||||
" ○ Title: Playing Games\n"
|
||||
" ○ Status: Online for 71 days\n"
|
||||
" ○ Link: https://stream.logal.dev\n"
|
||||
"To unsubscribe from any of these Owncast "
|
||||
"instances, use `!unsubscribe <domain>`"
|
||||
)
|
||||
|
||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||
async def test_shows_offline_stream(self, maubot_test_bot, maubot_plugin) -> None:
|
||||
"""Show offline status for non-live streams."""
|
||||
# Subscribe first
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
# Update stream state to be offline
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="stream.logal.dev",
|
||||
name="Test Stream",
|
||||
last_disconnect_time="2026-01-01T10:00:00Z",
|
||||
)
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"**Subscriptions for this room (1):**\n\n"
|
||||
"● **Test Stream** \n"
|
||||
" ○ Status: Offline for 71 days\n"
|
||||
" ○ Link: https://stream.logal.dev\n"
|
||||
"To unsubscribe from any of these Owncast "
|
||||
"instances, use `!unsubscribe <domain>`"
|
||||
)
|
||||
|
||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||
async def test_shows_multiple_subscriptions(
|
||||
self, maubot_test_bot, maubot_plugin
|
||||
) -> None:
|
||||
"""List subscriptions alphabetically with mixed statuses."""
|
||||
# Subscribe in reverse alphabetical order to verify sorted output
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
f"https://beta.com{OWNCAST_STATUS_PATH}",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
mocked.get(
|
||||
f"https://alpha.com{OWNCAST_STATUS_PATH}",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe beta.com")
|
||||
await maubot_test_bot.send("!subscribe alpha.com")
|
||||
|
||||
# Set alpha online, beta offline
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="alpha.com",
|
||||
name="Alpha Stream",
|
||||
title="Streaming Live",
|
||||
last_connect_time="2026-03-13T10:00:00Z",
|
||||
)
|
||||
)
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="beta.com",
|
||||
name="Beta Stream",
|
||||
last_disconnect_time="2026-03-12T18:00:00Z",
|
||||
)
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 3
|
||||
assert maubot_test_bot.responded[2].content.body == (
|
||||
"**Subscriptions for this room (2):**\n\n"
|
||||
"● **Alpha Stream**\n"
|
||||
" \n"
|
||||
" ○ Title: Streaming Live\n"
|
||||
" ○ Status: Online for 2 hours\n"
|
||||
" ○ Link: https://alpha.com\n"
|
||||
"● **Beta Stream**\n"
|
||||
" \n"
|
||||
" ○ Status: Offline for 18 hours\n"
|
||||
" ○ Link: https://beta.com\n"
|
||||
"To unsubscribe from any of these Owncast "
|
||||
"instances, use `!unsubscribe <domain>`"
|
||||
)
|
||||
|
||||
|
||||
class TestLiveCommand:
|
||||
"""Live streams listing command end-to-end via maubot."""
|
||||
|
||||
async def test_no_subscriptions(self, maubot_test_bot, maubot_plugin) -> None:
|
||||
"""Show help text when no subscriptions exist."""
|
||||
await maubot_test_bot.send("!live")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 1
|
||||
assert maubot_test_bot.responded[0].content.body == (
|
||||
"This room is not subscribed to any Owncast "
|
||||
"instances.\n\nTo subscribe to an Owncast "
|
||||
"instance, use `!subscribe <domain>`"
|
||||
)
|
||||
|
||||
async def test_no_live_streams(self, maubot_test_bot, maubot_plugin) -> None:
|
||||
"""Show 'no live' message when all streams are offline."""
|
||||
# Subscribe first
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
# Update stream state to offline
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="stream.logal.dev",
|
||||
name="Test Stream",
|
||||
last_disconnect_time="2026-01-01T10:00:00Z",
|
||||
)
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!live")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"No subscribed Owncast instances are currently "
|
||||
"live.\n\nUse `!subscriptions` to list all "
|
||||
"subscriptions."
|
||||
)
|
||||
|
||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||
async def test_shows_live_stream(self, maubot_test_bot, maubot_plugin) -> None:
|
||||
"""Show live stream with title and duration."""
|
||||
# Subscribe first
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
# Update stream state to online
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="stream.logal.dev",
|
||||
name="Test Stream",
|
||||
title="Playing Games",
|
||||
last_connect_time="2026-01-01T12:00:00Z",
|
||||
)
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!live")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 2
|
||||
assert maubot_test_bot.responded[1].content.body == (
|
||||
"**Live Owncast instances (1):**\n\n"
|
||||
"● **Test Stream** \n"
|
||||
" ○ Title: Playing Games\n"
|
||||
" ○ Online for 71 days\n"
|
||||
" ○ Link: https://stream.logal.dev"
|
||||
)
|
||||
|
||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||
async def test_shows_multiple_live_streams(
|
||||
self, maubot_test_bot, maubot_plugin
|
||||
) -> None:
|
||||
"""List live streams alphabetically with different durations."""
|
||||
# Subscribe in reverse alphabetical order to verify sorted output
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
f"https://beta.com{OWNCAST_STATUS_PATH}",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
mocked.get(
|
||||
f"https://alpha.com{OWNCAST_STATUS_PATH}",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe beta.com")
|
||||
await maubot_test_bot.send("!subscribe alpha.com")
|
||||
|
||||
# Set both streams online with different connect times
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="alpha.com",
|
||||
name="Alpha Stream",
|
||||
title="Morning Show",
|
||||
last_connect_time="2026-03-13T10:00:00Z",
|
||||
)
|
||||
)
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="beta.com",
|
||||
name="Beta Stream",
|
||||
title="Evening Vibes",
|
||||
last_connect_time="2026-03-13T06:00:00Z",
|
||||
)
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!live")
|
||||
|
||||
assert len(maubot_test_bot.responded) == 3
|
||||
assert maubot_test_bot.responded[2].content.body == (
|
||||
"**Live Owncast instances (2):**\n\n"
|
||||
"● **Alpha Stream**\n"
|
||||
" \n"
|
||||
" ○ Title: Morning Show\n"
|
||||
" ○ Online for 2 hours\n"
|
||||
" ○ Link: https://alpha.com\n"
|
||||
"● **Beta Stream**\n"
|
||||
" \n"
|
||||
" ○ Title: Evening Vibes\n"
|
||||
" ○ Online for 6 hours\n"
|
||||
" ○ Link: https://beta.com"
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
# 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 __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owncastsentry.database import StreamRepository, SubscriptionRepository
|
||||
|
||||
|
||||
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 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 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 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
|
||||
@@ -0,0 +1,171 @@
|
||||
# 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 the health checking service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from aioresponses import aioresponses
|
||||
|
||||
from owncastsentry.health_checker import HealthChecker, HealthStatus, UpdateResult
|
||||
from owncastsentry.owncast_client import OwncastClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def health_checker(database: Database) -> AsyncIterator[HealthChecker]:
|
||||
"""Yield a HealthChecker backed by a real OwncastClient."""
|
||||
client = OwncastClient(logger=logging.getLogger("test"), version="0.0.0")
|
||||
yield HealthChecker(database, client, logging.getLogger("test"))
|
||||
await client.close()
|
||||
|
||||
|
||||
class TestUpdateResultHttpHealthy:
|
||||
"""HTTP health derivation from update results."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("total", "successful", "failed", "expected"),
|
||||
[
|
||||
pytest.param(0, 0, 0, True, id="no-streams-is-healthy"),
|
||||
pytest.param(3, 2, 1, True, id="some-successes-is-healthy"),
|
||||
pytest.param(3, 0, 3, False, id="all-failures-is-unhealthy"),
|
||||
],
|
||||
)
|
||||
def test_http_healthy(
|
||||
self, total: int, successful: int, failed: int, expected: bool
|
||||
) -> None:
|
||||
"""Derive HTTP health from stream check results."""
|
||||
result = UpdateResult(
|
||||
total_streams=total,
|
||||
successful_checks=successful,
|
||||
failed_checks=failed,
|
||||
)
|
||||
assert result.http_healthy is expected
|
||||
|
||||
|
||||
class TestHealthStatusIsHealthy:
|
||||
"""Overall health status derivation."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("db_healthy", "http_healthy", "expected"),
|
||||
[
|
||||
pytest.param(True, True, True, id="all-healthy"),
|
||||
pytest.param(False, True, False, id="db-unhealthy"),
|
||||
pytest.param(True, False, False, id="http-unhealthy"),
|
||||
pytest.param(False, False, False, id="both-unhealthy"),
|
||||
],
|
||||
)
|
||||
def test_is_healthy(
|
||||
self, db_healthy: bool, http_healthy: bool, expected: bool
|
||||
) -> None:
|
||||
"""Derive overall health from component health."""
|
||||
status = HealthStatus(database_healthy=db_healthy, http_healthy=http_healthy)
|
||||
assert status.is_healthy is expected
|
||||
|
||||
|
||||
class TestCheckDatabase:
|
||||
"""Database health check."""
|
||||
|
||||
async def test_returns_true_for_healthy_db(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Return True when the database responds to queries."""
|
||||
assert await health_checker.check_database() is True
|
||||
|
||||
async def test_returns_false_for_stopped_db(
|
||||
self, health_checker: HealthChecker, database: Database
|
||||
) -> None:
|
||||
"""Return False when the database connection is closed."""
|
||||
await database.stop()
|
||||
assert await health_checker.check_database() is False
|
||||
|
||||
|
||||
class TestPerformHealthCheck:
|
||||
"""Health check orchestration and endpoint reporting."""
|
||||
|
||||
async def test_skips_report_when_no_endpoint(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Skip reporting when endpoint is empty."""
|
||||
result = UpdateResult(total_streams=0, successful_checks=0, failed_checks=0)
|
||||
with aioresponses():
|
||||
await health_checker.perform_health_check(result, "")
|
||||
|
||||
async def test_skips_report_when_unhealthy(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Skip health report when all stream checks failed."""
|
||||
result = UpdateResult(total_streams=3, successful_checks=0, failed_checks=3)
|
||||
with aioresponses():
|
||||
await health_checker.perform_health_check(
|
||||
result, "https://health.example.com/ping"
|
||||
)
|
||||
|
||||
async def test_sends_report_when_healthy(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Send GET to endpoint when all checks pass."""
|
||||
result = UpdateResult(total_streams=1, successful_checks=1, failed_checks=0)
|
||||
with aioresponses() as mocked:
|
||||
mocked.get("https://health.example.com/ping", status=200)
|
||||
await health_checker.perform_health_check(
|
||||
result, "https://health.example.com/ping"
|
||||
)
|
||||
|
||||
async def test_skips_report_for_whitespace_endpoint(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Skip reporting when endpoint is whitespace."""
|
||||
result = UpdateResult(total_streams=0, successful_checks=0, failed_checks=0)
|
||||
with aioresponses():
|
||||
await health_checker.perform_health_check(result, " ")
|
||||
|
||||
|
||||
class TestSendHealthReport:
|
||||
"""Health report HTTP delivery."""
|
||||
|
||||
async def test_handles_success_response(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Complete without error on a 2xx response."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get("https://health.example.com/ping", status=200)
|
||||
await health_checker._send_health_report("https://health.example.com/ping")
|
||||
|
||||
async def test_handles_non_success_response(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Complete without error on a non-2xx response."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get("https://health.example.com/ping", status=500)
|
||||
await health_checker._send_health_report("https://health.example.com/ping")
|
||||
|
||||
async def test_handles_connection_error(
|
||||
self, health_checker: HealthChecker
|
||||
) -> None:
|
||||
"""Complete without error on a connection failure."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://health.example.com/ping",
|
||||
exception=ConnectionError(),
|
||||
)
|
||||
await health_checker._send_health_report("https://health.example.com/ping")
|
||||
@@ -0,0 +1,195 @@
|
||||
# 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 data models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.models import StreamConfig, StreamState, StreamStatus
|
||||
from owncastsentry.utils import (
|
||||
MAX_INSTANCE_TITLE_LENGTH,
|
||||
MAX_STREAM_TITLE_LENGTH,
|
||||
MAX_TAG_LENGTH,
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
)
|
||||
|
||||
|
||||
class TestStreamStateStatus:
|
||||
"""Stream status derivation from state fields."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure_counter", "last_connect_time", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD + 1,
|
||||
None,
|
||||
StreamStatus.UNKNOWN,
|
||||
id="above-threshold-offline-returns-unknown",
|
||||
),
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD + 1,
|
||||
"2026-01-01T00:00:00Z",
|
||||
StreamStatus.UNKNOWN,
|
||||
id="above-threshold-online-returns-unknown",
|
||||
),
|
||||
pytest.param(
|
||||
0,
|
||||
"2026-01-01T00:00:00Z",
|
||||
StreamStatus.ONLINE,
|
||||
id="zero-failures-with-connect-time-returns-online",
|
||||
),
|
||||
pytest.param(
|
||||
0,
|
||||
None,
|
||||
StreamStatus.OFFLINE,
|
||||
id="zero-failures-no-connect-time-returns-offline",
|
||||
),
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
"2026-01-01T00:00:00Z",
|
||||
StreamStatus.ONLINE,
|
||||
id="at-threshold-with-connect-time-returns-online",
|
||||
),
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
None,
|
||||
StreamStatus.OFFLINE,
|
||||
id="at-threshold-no-connect-time-returns-offline",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_status(
|
||||
self,
|
||||
failure_counter: int,
|
||||
last_connect_time: str | None,
|
||||
expected: StreamStatus,
|
||||
) -> None:
|
||||
"""Return the correct status based on failure counter and connect time."""
|
||||
state = StreamState(
|
||||
domain="example.com",
|
||||
failure_counter=failure_counter,
|
||||
last_connect_time=last_connect_time,
|
||||
)
|
||||
assert state.status is expected
|
||||
|
||||
|
||||
class TestStreamStateFromApiResponse:
|
||||
"""StreamState construction from an API response dictionary."""
|
||||
|
||||
def test_typical_response(self) -> None:
|
||||
"""Populate all fields from a complete API response."""
|
||||
response = {
|
||||
"streamTitle": "My Stream",
|
||||
"lastConnectTime": "2026-01-01T00:00:00Z",
|
||||
"lastDisconnectTime": "2025-12-31T23:00:00Z",
|
||||
}
|
||||
state = StreamState.from_api_response(response, "example.com")
|
||||
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.name is None
|
||||
assert state.failure_counter == 0
|
||||
|
||||
def test_empty_response_defaults(self) -> None:
|
||||
"""Use defaults when optional fields are missing."""
|
||||
state = StreamState.from_api_response({}, "bare.example.com")
|
||||
assert state.domain == "bare.example.com"
|
||||
assert state.title == ""
|
||||
assert state.last_connect_time is None
|
||||
assert state.last_disconnect_time is None
|
||||
|
||||
def test_title_truncation(self) -> None:
|
||||
"""Truncate the stream title to MAX_STREAM_TITLE_LENGTH."""
|
||||
long_title = "A" * (MAX_STREAM_TITLE_LENGTH + 50)
|
||||
response = {"streamTitle": long_title}
|
||||
state = StreamState.from_api_response(response, "example.com")
|
||||
assert len(state.title) == MAX_STREAM_TITLE_LENGTH
|
||||
assert state.title == "A" * MAX_STREAM_TITLE_LENGTH
|
||||
|
||||
|
||||
class TestStreamStateFromDbRow:
|
||||
"""StreamState construction from a database row dictionary."""
|
||||
|
||||
def test_typical_row(self) -> None:
|
||||
"""Populate all fields from a complete database row."""
|
||||
row = {
|
||||
"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",
|
||||
"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.failure_counter == 3
|
||||
|
||||
def test_row_with_none_optional_fields(self) -> None:
|
||||
"""Accept None for optional fields in a database row."""
|
||||
row = {
|
||||
"domain": "example.com",
|
||||
"name": None,
|
||||
"title": None,
|
||||
"last_connect_time": None,
|
||||
"last_disconnect_time": 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.failure_counter == 0
|
||||
|
||||
|
||||
class TestStreamConfigFromApiResponse:
|
||||
"""StreamConfig 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)
|
||||
assert config.name == "My Instance"
|
||||
assert config.tags == ["gaming", "music"]
|
||||
|
||||
def test_missing_keys_defaults(self) -> None:
|
||||
"""Use defaults when name and tags keys are missing."""
|
||||
config = StreamConfig.from_api_response({})
|
||||
assert config.name == ""
|
||||
assert config.tags == []
|
||||
|
||||
def test_name_truncation(self) -> None:
|
||||
"""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)
|
||||
assert len(config.name) == MAX_INSTANCE_TITLE_LENGTH
|
||||
assert config.name == "B" * MAX_INSTANCE_TITLE_LENGTH
|
||||
|
||||
def test_tag_truncation(self) -> None:
|
||||
"""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)
|
||||
assert len(config.tags[0]) == MAX_TAG_LENGTH
|
||||
assert config.tags[0] == "C" * MAX_TAG_LENGTH
|
||||
assert config.tags[1] == "short"
|
||||
@@ -0,0 +1,348 @@
|
||||
# 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 the notification service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.notification_service import NotificationService
|
||||
from owncastsentry.utils import SECONDS_BETWEEN_NOTIFICATIONS
|
||||
from tests.conftest import _StubMatrixClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owncastsentry.database import StreamRepository, SubscriptionRepository
|
||||
|
||||
|
||||
def _make_service(
|
||||
*,
|
||||
client: _StubMatrixClient,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> NotificationService:
|
||||
"""Build a NotificationService with a stub client and real repo."""
|
||||
return NotificationService(
|
||||
client=client,
|
||||
subscription_repo=subscription_repo,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
|
||||
class TestCanNotify:
|
||||
"""Rate-limiting logic for notification cooldowns."""
|
||||
|
||||
def test_first_notification_allowed(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Allow the first notification for an unseen domain."""
|
||||
service = _make_service(
|
||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||
)
|
||||
assert service._can_notify("example.com") is True
|
||||
|
||||
def test_within_cooldown_blocked(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Block notifications within the cooldown window."""
|
||||
service = _make_service(
|
||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||
)
|
||||
service.notification_timers_cache["example.com"] = time.time()
|
||||
assert service._can_notify("example.com") is False
|
||||
|
||||
def test_after_cooldown_allowed(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Allow notifications after the cooldown window expires."""
|
||||
service = _make_service(
|
||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||
)
|
||||
# Subtract an extra second to ensure the cooldown has fully elapsed
|
||||
service.notification_timers_cache["example.com"] = (
|
||||
time.time() - SECONDS_BETWEEN_NOTIFICATIONS - 1
|
||||
)
|
||||
assert service._can_notify("example.com") is True
|
||||
|
||||
|
||||
class TestGetLastNotificationTime:
|
||||
"""Last notification timestamp retrieval."""
|
||||
|
||||
def test_returns_cached_value(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return the cached timestamp for a known domain."""
|
||||
service = _make_service(
|
||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||
)
|
||||
service.notification_timers_cache["example.com"] = 12345.0
|
||||
assert service.get_last_notification_time("example.com") == 12345.0
|
||||
|
||||
def test_returns_zero_for_unknown(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return 0 for a domain that has never been notified."""
|
||||
service = _make_service(
|
||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||
)
|
||||
assert service.get_last_notification_time("unknown.com") == 0
|
||||
|
||||
|
||||
class TestFormatMessage:
|
||||
"""Notification message formatting."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "title", "domain", "tags", "title_change", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
"My Stream",
|
||||
"Playing Games",
|
||||
"example.com",
|
||||
[],
|
||||
False,
|
||||
"🎥 My Stream is now live!\n"
|
||||
"Stream Title: Playing Games\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/",
|
||||
id="go-live-with-title",
|
||||
),
|
||||
pytest.param(
|
||||
"My Stream",
|
||||
"New Title",
|
||||
"example.com",
|
||||
[],
|
||||
True,
|
||||
"📝 My Stream has changed its stream title!\n"
|
||||
"Stream Title: New Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/",
|
||||
id="title-change",
|
||||
),
|
||||
pytest.param(
|
||||
"My Stream",
|
||||
"",
|
||||
"example.com",
|
||||
[],
|
||||
False,
|
||||
"🎥 My Stream is now live!\n\nTo tune in, visit: https://example.com/",
|
||||
id="go-live-no-title",
|
||||
),
|
||||
pytest.param(
|
||||
"",
|
||||
"Title",
|
||||
"example.com",
|
||||
[],
|
||||
False,
|
||||
"🎥 example.com is now live!\n"
|
||||
"Stream Title: Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/",
|
||||
id="name-fallback-to-domain",
|
||||
),
|
||||
pytest.param(
|
||||
"Stream",
|
||||
"Title",
|
||||
"example.com",
|
||||
["gaming", "fun"],
|
||||
False,
|
||||
"🎥 Stream is now live!\n"
|
||||
"Stream Title: Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/\n"
|
||||
"\n"
|
||||
"#gaming #fun",
|
||||
id="with-tags",
|
||||
),
|
||||
pytest.param(
|
||||
"Stream",
|
||||
"Title",
|
||||
"example.com",
|
||||
[".hidden", "visible"],
|
||||
False,
|
||||
"🎥 Stream is now live!\n"
|
||||
"Stream Title: Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/\n"
|
||||
"\n"
|
||||
"#visible",
|
||||
id="dot-prefix-tag-filtered",
|
||||
),
|
||||
pytest.param(
|
||||
"Stream",
|
||||
"Title",
|
||||
"example.com",
|
||||
[".secret"],
|
||||
False,
|
||||
"🎥 Stream is now live!\n"
|
||||
"Stream Title: Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/",
|
||||
id="all-tags-dot-prefixed",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_format_message(
|
||||
self,
|
||||
name: str,
|
||||
title: str,
|
||||
domain: str,
|
||||
tags: list[str],
|
||||
title_change: bool,
|
||||
expected: str,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Format the notification message with expected content."""
|
||||
service = _make_service(
|
||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||
)
|
||||
result = service._format_message(name, title, domain, tags, title_change)
|
||||
assert result == expected
|
||||
|
||||
|
||||
class TestNotifyStreamLive:
|
||||
"""End-to-end notification sending."""
|
||||
|
||||
async def test_sends_to_all_subscribed_rooms(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Send notifications to every room subscribed to the domain."""
|
||||
client = _StubMatrixClient()
|
||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room1:matrix.org")
|
||||
await subscription_repo.add("example.com", "!room2:matrix.org")
|
||||
|
||||
await service.notify_stream_live("example.com", "Stream", "Title", ["tag"])
|
||||
|
||||
assert len(client.sent_messages) == 2
|
||||
room_ids = {msg.room_id for msg in client.sent_messages}
|
||||
assert room_ids == {"!room1:matrix.org", "!room2:matrix.org"}
|
||||
expected_body = (
|
||||
"🎥 Stream is now live!\n"
|
||||
"Stream Title: Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/\n"
|
||||
"\n"
|
||||
"#tag"
|
||||
)
|
||||
for msg in client.sent_messages:
|
||||
assert msg.content.body == expected_body
|
||||
|
||||
async def test_skips_when_rate_limited(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Skip sending when the domain is within the rate-limit cooldown."""
|
||||
client = _StubMatrixClient()
|
||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||
service.notification_timers_cache["example.com"] = time.time()
|
||||
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room1:matrix.org")
|
||||
|
||||
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||
|
||||
assert len(client.sent_messages) == 0
|
||||
|
||||
async def test_counts_failures(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Send to remaining rooms after a delivery failure."""
|
||||
client = _StubMatrixClient()
|
||||
client.should_fail_for_rooms.add("!bad:matrix.org")
|
||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!bad:matrix.org")
|
||||
await subscription_repo.add("example.com", "!good:matrix.org")
|
||||
|
||||
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].room_id == "!good:matrix.org"
|
||||
assert client.sent_messages[0].content.body == (
|
||||
"🎥 Stream is now live!\n"
|
||||
"Stream Title: Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://example.com/"
|
||||
)
|
||||
|
||||
|
||||
class TestSendCleanupWarning:
|
||||
"""Cleanup warning notification sending."""
|
||||
|
||||
async def test_sends_warning_to_all_rooms(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Send cleanup warning to all subscribed rooms."""
|
||||
client = _StubMatrixClient()
|
||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room1:matrix.org")
|
||||
|
||||
await service.send_cleanup_warning("example.com")
|
||||
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].content.body == (
|
||||
"⚠️ Warning: Subscription Cleanup Scheduled\n"
|
||||
"\n"
|
||||
"The Owncast instance at example.com has been "
|
||||
"unreachable for 83 days. If it remains "
|
||||
"unreachable for 7 more days "
|
||||
"(90 days total), this subscription "
|
||||
"will be automatically removed."
|
||||
)
|
||||
|
||||
|
||||
class TestSendCleanupDeletion:
|
||||
"""Cleanup deletion notification sending."""
|
||||
|
||||
async def test_sends_deletion_to_all_rooms(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Send cleanup deletion notice to all subscribed rooms."""
|
||||
client = _StubMatrixClient()
|
||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||
|
||||
await stream_repo.create("example.com")
|
||||
await subscription_repo.add("example.com", "!room1:matrix.org")
|
||||
|
||||
await service.send_cleanup_deletion("example.com")
|
||||
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].content.body == (
|
||||
"🗑️ Subscription Automatically Removed\n"
|
||||
"\n"
|
||||
"The Owncast instance at example.com has been "
|
||||
"unreachable for 90 days and has been "
|
||||
"automatically removed from subscriptions in this "
|
||||
"room.\n"
|
||||
"\n"
|
||||
"If the instance comes online again and you want to "
|
||||
"resubscribe, run `!subscribe example.com`."
|
||||
)
|
||||
@@ -0,0 +1,207 @@
|
||||
# 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 the Owncast HTTP client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from aioresponses import aioresponses
|
||||
|
||||
from owncastsentry.owncast_client import OwncastClient
|
||||
from tests.conftest import VALID_CONFIG_RESPONSE, VALID_STATUS_RESPONSE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def owncast_client() -> AsyncIterator[OwncastClient]:
|
||||
"""Create an OwncastClient and close it after the test."""
|
||||
client = OwncastClient(logger=logging.getLogger("test"), version="0.0.0")
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
class TestGetStreamState:
|
||||
"""Stream state retrieval from the status API."""
|
||||
|
||||
async def test_returns_state_on_success(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return a StreamState with correct fields on a valid 200 response."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is not None
|
||||
assert result.domain == "stream.logal.dev"
|
||||
assert (
|
||||
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"
|
||||
|
||||
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}
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=json.dumps(incomplete).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_invalid_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response body is not valid JSON."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=b"not json",
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
|
||||
"""Return None when the response status is not 200."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
status=404,
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_connection_error(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when a connection error occurs."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
exception=ConnectionError(),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetStreamConfig:
|
||||
"""Stream configuration retrieval from the config API."""
|
||||
|
||||
async def test_returns_config_on_success(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return a StreamConfig with correct fields on a valid 200 response."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
body=json.dumps(VALID_CONFIG_RESPONSE).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is not None
|
||||
assert result.name == "LogalDeveloper's Live Stream"
|
||||
assert result.tags == [
|
||||
"video games",
|
||||
"chatting",
|
||||
"casual",
|
||||
"english",
|
||||
"streaming",
|
||||
"owncast",
|
||||
"variety",
|
||||
]
|
||||
|
||||
async def test_returns_none_on_invalid_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response body is not valid JSON."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
body=b"not json",
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
|
||||
"""Return None when the response status is not 200."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
status=500,
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_connection_error(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when a connection error occurs."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
exception=ConnectionError(),
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestValidateInstance:
|
||||
"""Owncast instance validation via the status API."""
|
||||
|
||||
async def test_returns_true_for_valid_instance(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return True when the status endpoint returns a valid response."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://valid.com/api/status",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
result = await owncast_client.validate_instance("valid.com")
|
||||
|
||||
assert result is True
|
||||
|
||||
async def test_returns_false_for_invalid_instance(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return False when the status endpoint returns a non-200 response."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://invalid.com/api/status",
|
||||
status=404,
|
||||
)
|
||||
result = await owncast_client.validate_instance("invalid.com")
|
||||
|
||||
assert result is False
|
||||
@@ -0,0 +1,839 @@
|
||||
# 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 the stream monitor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from owncastsentry.models import StreamConfig, StreamState
|
||||
from owncastsentry.notification_service import NotificationService
|
||||
from owncastsentry.stream_monitor import StreamMonitor
|
||||
from owncastsentry.utils import (
|
||||
CLEANUP_DELETE_THRESHOLD,
|
||||
CLEANUP_WARNING_THRESHOLD,
|
||||
SECONDS_BETWEEN_NOTIFICATIONS,
|
||||
)
|
||||
from tests.conftest import _StubMatrixClient, _StubOwncastClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owncastsentry.database import StreamRepository, SubscriptionRepository
|
||||
|
||||
|
||||
def _make_monitor(
|
||||
*,
|
||||
owncast_client: _StubOwncastClient,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
client: _StubMatrixClient,
|
||||
) -> tuple[StreamMonitor, NotificationService]:
|
||||
"""Build a StreamMonitor with stubs and a real NotificationService."""
|
||||
logger = logging.getLogger("test")
|
||||
notification_service = NotificationService(
|
||||
client=client,
|
||||
subscription_repo=subscription_repo,
|
||||
logger=logger,
|
||||
)
|
||||
monitor = StreamMonitor(
|
||||
owncast_client=owncast_client,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
notification_service=notification_service,
|
||||
logger=logger,
|
||||
)
|
||||
return monitor, notification_service
|
||||
|
||||
|
||||
async def _seed_stream(
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
*,
|
||||
domain: str = "example.com",
|
||||
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,
|
||||
) -> None:
|
||||
"""Insert a stream and subscription into the database."""
|
||||
await stream_repo.create(domain)
|
||||
state = StreamState(
|
||||
domain=domain,
|
||||
name=name,
|
||||
title=title,
|
||||
last_connect_time=last_connect_time,
|
||||
last_disconnect_time=last_disconnect_time,
|
||||
)
|
||||
await stream_repo.update(state)
|
||||
await subscription_repo.add(domain, room_id)
|
||||
|
||||
|
||||
class TestUpdateAllStreams:
|
||||
"""Parallel stream update orchestration."""
|
||||
|
||||
async def test_returns_correct_counts(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""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"
|
||||
),
|
||||
stream_config=StreamConfig(name="OK"),
|
||||
)
|
||||
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="ok.com")
|
||||
|
||||
result = await monitor.update_all_streams(["ok.com"])
|
||||
assert result.total_streams == 1
|
||||
assert result.successful_checks == 1
|
||||
assert result.failed_checks == 0
|
||||
assert owncast.queried_domains == ["ok.com"]
|
||||
|
||||
|
||||
class TestUpdateStreamBackoff:
|
||||
"""Backoff logic that skips queries for failing streams."""
|
||||
|
||||
async def test_skips_query_on_backoff(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Skip the HTTP query and increment the counter during backoff."""
|
||||
owncast = _StubOwncastClient()
|
||||
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="fail.com")
|
||||
|
||||
# Set failure counter to 5 (should skip odd cycles)
|
||||
for _ in range(5):
|
||||
await stream_repo.increment_failure_counter("fail.com")
|
||||
|
||||
result = await monitor.update_stream("fail.com")
|
||||
assert result is True
|
||||
assert owncast.state_call_count == 0
|
||||
|
||||
# Counter should have been incremented to 6
|
||||
state = await stream_repo.get_by_domain("fail.com")
|
||||
assert state is not None
|
||||
assert state.failure_counter == 6
|
||||
|
||||
|
||||
class TestUpdateStreamFirstUpdate:
|
||||
"""First state update of a newly subscribed stream."""
|
||||
|
||||
async def test_no_notification_on_first_live(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Skip notifications when a stream is already live on first update."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
domain="new.com",
|
||||
last_connect_time="2026-01-01T00:00:00Z",
|
||||
last_disconnect_time="2025-12-31T00:00:00Z",
|
||||
),
|
||||
stream_config=StreamConfig(name="New Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
|
||||
# Seed with no connect/disconnect times (brand new)
|
||||
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
|
||||
|
||||
async def test_no_notification_on_first_offline(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Skip notifications when a stream is offline on first update."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
domain="new.com",
|
||||
last_disconnect_time="2025-12-31T00:00:00Z",
|
||||
),
|
||||
stream_config=StreamConfig(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
|
||||
|
||||
|
||||
class TestUpdateStreamGoesLive:
|
||||
"""Stream transitioning from offline to online."""
|
||||
|
||||
async def test_sends_go_live_notification(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Send go-live with stream name and tags."""
|
||||
owncast = _StubOwncastClient(
|
||||
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",
|
||||
),
|
||||
stream_config=StreamConfig(name="Live Stream", tags=["gaming"]),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
|
||||
# Seed as offline (has disconnect but no connect)
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="live.com",
|
||||
last_disconnect_time="2026-01-01T10:00:00Z",
|
||||
)
|
||||
|
||||
# Set offline timer to long ago so it's not a brief outage
|
||||
monitor.offline_timer_cache["live.com"] = 0
|
||||
|
||||
result = await monitor.update_stream("live.com")
|
||||
assert result is True
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].content.body == (
|
||||
"🎥 Live Stream is now live!\n"
|
||||
"Stream Title: Now Streaming\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://live.com/\n"
|
||||
"\n"
|
||||
"#gaming"
|
||||
)
|
||||
|
||||
async def test_falls_back_when_config_fetch_fails(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Use domain as name in go-live when config fetch fails."""
|
||||
owncast = _StubOwncastClient(
|
||||
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",
|
||||
),
|
||||
stream_config=None,
|
||||
)
|
||||
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="live.com",
|
||||
last_disconnect_time="2026-01-01T10:00:00Z",
|
||||
)
|
||||
|
||||
monitor.offline_timer_cache["live.com"] = 0
|
||||
|
||||
result = await monitor.update_stream("live.com")
|
||||
assert result is True
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].content.body == (
|
||||
"🎥 live.com is now live!\n"
|
||||
"Stream Title: Now Streaming\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://live.com/"
|
||||
)
|
||||
|
||||
|
||||
class TestUpdateStreamBriefOffline:
|
||||
"""Stream that was briefly offline and comes back."""
|
||||
|
||||
async def test_no_notification_same_title(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Skip notification for a brief outage with no title change."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
domain="brief.com",
|
||||
title="Same Title",
|
||||
last_connect_time="2026-01-01T12:00:00Z",
|
||||
),
|
||||
stream_config=StreamConfig(name="Brief Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
|
||||
# Seed as offline with same title
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="brief.com",
|
||||
title="Same Title",
|
||||
last_disconnect_time="2026-01-01T11:55:00Z",
|
||||
)
|
||||
|
||||
# Recently offline (within cooldown)
|
||||
monitor.offline_timer_cache["brief.com"] = time.time() - 60
|
||||
|
||||
result = await monitor.update_stream("brief.com")
|
||||
assert result is True
|
||||
assert len(client.sent_messages) == 0
|
||||
|
||||
async def test_title_change_notification(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Send a title-change notification for a brief outage with a new title."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
domain="brief.com",
|
||||
title="New Title",
|
||||
last_connect_time="2026-01-01T12:00:00Z",
|
||||
),
|
||||
stream_config=StreamConfig(name="Brief Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
|
||||
# Seed as offline with old title
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="brief.com",
|
||||
title="Old Title",
|
||||
last_disconnect_time="2026-01-01T11:55:00Z",
|
||||
)
|
||||
|
||||
# Recently offline (within cooldown)
|
||||
monitor.offline_timer_cache["brief.com"] = time.time() - 60
|
||||
|
||||
result = await monitor.update_stream("brief.com")
|
||||
assert result is True
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].content.body == (
|
||||
"📝 Brief Stream has changed its stream title!\n"
|
||||
"Stream Title: New Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://brief.com/"
|
||||
)
|
||||
|
||||
|
||||
class TestUpdateStreamTitleChange:
|
||||
"""Mid-session title change while stream stays online."""
|
||||
|
||||
async def test_falls_back_when_config_fetch_fails(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Use domain as name in title-change when config fails."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
domain="title.com",
|
||||
title="Updated Title",
|
||||
last_connect_time="2026-01-01T12:00:00Z",
|
||||
),
|
||||
stream_config=None,
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, notification_service = _make_monitor(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="title.com",
|
||||
title="Original Title",
|
||||
last_connect_time="2026-01-01T12:00:00Z",
|
||||
)
|
||||
|
||||
monitor.offline_timer_cache["title.com"] = 0
|
||||
# Subtract an extra second to ensure the cooldown has fully elapsed
|
||||
notification_service.notification_timers_cache["title.com"] = (
|
||||
time.time() - SECONDS_BETWEEN_NOTIFICATIONS - 1
|
||||
)
|
||||
|
||||
result = await monitor.update_stream("title.com")
|
||||
assert result is True
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].content.body == (
|
||||
"📝 title.com has changed its stream title!\n"
|
||||
"Stream Title: Updated Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://title.com/"
|
||||
)
|
||||
|
||||
async def test_sends_title_change(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Send a title-change notification when the title changes mid-stream."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
domain="title.com",
|
||||
title="Updated Title",
|
||||
last_connect_time="2026-01-01T12:00:00Z",
|
||||
),
|
||||
stream_config=StreamConfig(name="Title Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, notification_service = _make_monitor(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
|
||||
# Seed as online with different title
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="title.com",
|
||||
title="Original Title",
|
||||
last_connect_time="2026-01-01T12:00:00Z",
|
||||
)
|
||||
|
||||
# Last notification was long enough ago to pass rate limiting,
|
||||
# but more recent than the offline timer (so title-change fires)
|
||||
monitor.offline_timer_cache["title.com"] = 0
|
||||
# Subtract an extra second to ensure the cooldown has fully elapsed
|
||||
notification_service.notification_timers_cache["title.com"] = (
|
||||
time.time() - SECONDS_BETWEEN_NOTIFICATIONS - 1
|
||||
)
|
||||
|
||||
result = await monitor.update_stream("title.com")
|
||||
assert result is True
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].content.body == (
|
||||
"📝 Title Stream has changed its stream title!\n"
|
||||
"Stream Title: Updated Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://title.com/"
|
||||
)
|
||||
|
||||
async def test_sends_go_live_when_last_notification_before_offline(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Send go-live instead of title-change after an offline gap."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
domain="title.com",
|
||||
title="Updated Title",
|
||||
last_connect_time="2026-01-01T12:00:00Z",
|
||||
),
|
||||
stream_config=StreamConfig(name="Title Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, notification_service = _make_monitor(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="title.com",
|
||||
title="Original Title",
|
||||
last_connect_time="2026-01-01T12:00:00Z",
|
||||
)
|
||||
|
||||
# Offline timer is MORE recent than last notification,
|
||||
# and both are old enough to pass rate limiting
|
||||
now = time.time()
|
||||
monitor.offline_timer_cache["title.com"] = (
|
||||
now - SECONDS_BETWEEN_NOTIFICATIONS - 100
|
||||
)
|
||||
notification_service.notification_timers_cache["title.com"] = (
|
||||
now - SECONDS_BETWEEN_NOTIFICATIONS - 200
|
||||
)
|
||||
|
||||
result = await monitor.update_stream("title.com")
|
||||
assert result is True
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].content.body == (
|
||||
"🎥 Title Stream is now live!\n"
|
||||
"Stream Title: Updated Title\n"
|
||||
"\n"
|
||||
"To tune in, visit: https://title.com/"
|
||||
)
|
||||
|
||||
|
||||
class TestUpdateStreamGoesOffline:
|
||||
"""Stream transitioning from online to offline."""
|
||||
|
||||
async def test_records_offline_time(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Record the offline time in the cache when a stream goes offline."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
domain="offline.com",
|
||||
title="Title",
|
||||
last_disconnect_time="2026-01-01T12:00:00Z",
|
||||
),
|
||||
stream_config=StreamConfig(name="Offline Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
|
||||
# Seed as online
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="offline.com",
|
||||
last_connect_time="2026-01-01T10:00:00Z",
|
||||
)
|
||||
monitor.offline_timer_cache["offline.com"] = 0
|
||||
|
||||
before = time.time()
|
||||
result = await monitor.update_stream("offline.com")
|
||||
after = time.time()
|
||||
|
||||
assert result is True
|
||||
assert before <= monitor.offline_timer_cache["offline.com"] <= after
|
||||
assert len(client.sent_messages) == 0
|
||||
|
||||
|
||||
class TestUpdateStreamConnectionFailure:
|
||||
"""Connection failure handling."""
|
||||
|
||||
async def test_returns_false_and_increments_counter(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return False and increment the failure counter on connection failure."""
|
||||
owncast = _StubOwncastClient(stream_state=None)
|
||||
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="fail.com",
|
||||
last_disconnect_time="2026-01-01T00:00:00Z",
|
||||
)
|
||||
|
||||
result = await monitor.update_stream("fail.com")
|
||||
assert result is False
|
||||
|
||||
state = await stream_repo.get_by_domain("fail.com")
|
||||
assert state is not None
|
||||
assert state.failure_counter == 1
|
||||
|
||||
|
||||
class TestCheckCleanupThresholds:
|
||||
"""Auto-cleanup warning and deletion thresholds."""
|
||||
|
||||
async def test_sends_warning_at_threshold(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Send a cleanup warning when the failure counter hits the 83-day threshold."""
|
||||
owncast = _StubOwncastClient()
|
||||
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="warn.com")
|
||||
|
||||
await monitor._check_cleanup_thresholds("warn.com", CLEANUP_WARNING_THRESHOLD)
|
||||
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].content.body == (
|
||||
"⚠️ Warning: Subscription Cleanup Scheduled\n"
|
||||
"\n"
|
||||
"The Owncast instance at warn.com has been "
|
||||
"unreachable for 83 days. If it remains "
|
||||
"unreachable for 7 more days "
|
||||
"(90 days total), this subscription "
|
||||
"will be automatically removed."
|
||||
)
|
||||
|
||||
async def test_deletes_at_threshold(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Delete all subscriptions and the stream record at the 90-day threshold."""
|
||||
owncast = _StubOwncastClient()
|
||||
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="delete.com")
|
||||
|
||||
await monitor._check_cleanup_thresholds("delete.com", CLEANUP_DELETE_THRESHOLD)
|
||||
|
||||
# Deletion notification sent
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].content.body == (
|
||||
"🗑️ Subscription Automatically Removed\n"
|
||||
"\n"
|
||||
"The Owncast instance at delete.com has been "
|
||||
"unreachable for 90 days and has been "
|
||||
"automatically removed from subscriptions in this "
|
||||
"room.\n"
|
||||
"\n"
|
||||
"If the instance comes online again and you want to "
|
||||
"resubscribe, run `!subscribe delete.com`."
|
||||
)
|
||||
|
||||
# Stream and subscriptions removed from DB
|
||||
assert await stream_repo.get_by_domain("delete.com") is None
|
||||
rooms = await subscription_repo.get_subscribed_rooms("delete.com")
|
||||
assert rooms == []
|
||||
|
||||
async def test_no_action_below_thresholds(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Take no action when the counter is below both thresholds."""
|
||||
owncast = _StubOwncastClient()
|
||||
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="ok.com")
|
||||
|
||||
await monitor._check_cleanup_thresholds("ok.com", 100)
|
||||
|
||||
assert len(client.sent_messages) == 0
|
||||
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",
|
||||
last_disconnect_time="2026-01-01T12:00:00Z",
|
||||
),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
|
||||
# Seed as offline with same disconnect time and title
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="stable.com",
|
||||
title="Same Title",
|
||||
last_disconnect_time="2026-01-01T12:00:00Z",
|
||||
)
|
||||
|
||||
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",
|
||||
last_disconnect_time="2026-01-01T12:00:00Z",
|
||||
),
|
||||
)
|
||||
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",
|
||||
last_disconnect_time="2026-01-01T12:00:00Z",
|
||||
)
|
||||
|
||||
# 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."""
|
||||
|
||||
async def test_counts_mixed_results(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return correct counts with one failure and one backoff skip."""
|
||||
# Stub returns None, so all actual fetches fail
|
||||
owncast = _StubOwncastClient(stream_state=None)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
|
||||
# "fail.com" will be fetched and fail (counter=0, no backoff)
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="fail.com",
|
||||
last_disconnect_time="2026-01-01T00:00:00Z",
|
||||
)
|
||||
|
||||
# "skip.com" will be skipped via backoff (returns True)
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="skip.com",
|
||||
room_id="!room2:matrix.org",
|
||||
last_disconnect_time="2026-01-01T00:00:00Z",
|
||||
)
|
||||
for _ in range(5):
|
||||
await stream_repo.increment_failure_counter("skip.com")
|
||||
|
||||
result = await monitor.update_all_streams(["fail.com", "skip.com"])
|
||||
assert result.total_streams == 2
|
||||
assert result.successful_checks == 1
|
||||
assert result.failed_checks == 1
|
||||
@@ -0,0 +1,199 @@
|
||||
# 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 utility functions and constants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.utils import (
|
||||
domainify,
|
||||
escape_markdown,
|
||||
sanitize_for_markdown,
|
||||
sanitize_for_plain_text,
|
||||
should_query_stream,
|
||||
truncate,
|
||||
user_agent,
|
||||
)
|
||||
|
||||
|
||||
class TestUserAgent:
|
||||
"""User-Agent header construction."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("version", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
"1.2.3",
|
||||
"OwncastSentry/1.2.3 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="semver",
|
||||
),
|
||||
pytest.param(
|
||||
"0.0.0",
|
||||
"OwncastSentry/0.0.0 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="zeroed",
|
||||
),
|
||||
pytest.param(
|
||||
"1.1.1.dev10+gf0146d061.d20260313",
|
||||
"OwncastSentry/1.1.1.dev10+gf0146d061.d20260313 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="dev-version",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_user_agent(self, version: str, expected: str) -> None:
|
||||
"""Build a correctly formatted User-Agent header."""
|
||||
assert user_agent(version) == expected
|
||||
|
||||
|
||||
class TestShouldQueryStream:
|
||||
"""Progressive backoff logic for stream polling."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("counter", "expected"),
|
||||
[
|
||||
pytest.param(0, True, id="counter-0-always-query"),
|
||||
pytest.param(1, True, id="counter-1-always-query"),
|
||||
pytest.param(4, True, id="counter-4-always-query"),
|
||||
pytest.param(5, False, id="counter-5-skip-odd"),
|
||||
pytest.param(6, True, id="counter-6-query-even"),
|
||||
pytest.param(9, False, id="counter-9-skip-odd"),
|
||||
pytest.param(10, False, id="counter-10-skip-not-mod-3"),
|
||||
pytest.param(12, True, id="counter-12-query-mod-3"),
|
||||
pytest.param(14, False, id="counter-14-skip-not-mod-3"),
|
||||
pytest.param(15, True, id="counter-15-query-mod-5"),
|
||||
pytest.param(16, False, id="counter-16-skip-not-mod-5"),
|
||||
pytest.param(20, True, id="counter-20-query-mod-5"),
|
||||
pytest.param(29, False, id="counter-29-skip-not-mod-5"),
|
||||
pytest.param(30, True, id="counter-30-query-mod-15"),
|
||||
pytest.param(31, False, id="counter-31-skip-not-mod-15"),
|
||||
pytest.param(45, True, id="counter-45-query-mod-15"),
|
||||
pytest.param(100, False, id="counter-100-skip-not-mod-15"),
|
||||
pytest.param(105, True, id="counter-105-query-mod-15"),
|
||||
],
|
||||
)
|
||||
def test_backoff_tiers(self, counter: int, expected: bool) -> None:
|
||||
"""Return the expected query decision for each backoff tier."""
|
||||
assert should_query_stream(counter) == expected
|
||||
|
||||
|
||||
class TestDomainify:
|
||||
"""Domain extraction and sanitization from user input."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_url", "expected"),
|
||||
[
|
||||
pytest.param("example.com", "example.com", id="bare-domain"),
|
||||
pytest.param("https://example.com", "example.com", id="https-url"),
|
||||
pytest.param("http://example.com", "example.com", id="http-url"),
|
||||
pytest.param("https://example.com:8080", "example.com", id="url-with-port"),
|
||||
pytest.param(
|
||||
"https://example.com/path/to/page",
|
||||
"example.com",
|
||||
id="url-with-path",
|
||||
),
|
||||
pytest.param(
|
||||
"user@stream.logal.dev",
|
||||
"stream.logal.dev",
|
||||
id="email-style",
|
||||
),
|
||||
pytest.param("EXAMPLE.COM", "example.com", id="uppercase"),
|
||||
pytest.param("exam!ple.com", "example.com", id="special-chars-stripped"),
|
||||
pytest.param(".example.com.", "example.com", id="leading-trailing-dots"),
|
||||
pytest.param("-example.com-", "example.com", id="leading-trailing-hyphens"),
|
||||
pytest.param(
|
||||
"sub.domain.example.com",
|
||||
"sub.domain.example.com",
|
||||
id="subdomain",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_extracts_domain(self, input_url: str, expected: str) -> None:
|
||||
"""Extract and sanitize the domain from various input formats."""
|
||||
assert domainify(input_url) == expected
|
||||
|
||||
|
||||
class TestTruncate:
|
||||
"""Text truncation to a maximum length."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "max_length", "expected"),
|
||||
[
|
||||
pytest.param("hello", 10, "hello", id="under-limit"),
|
||||
pytest.param("hello", 5, "hello", id="exact-limit"),
|
||||
pytest.param("hello world", 5, "hello", id="over-limit"),
|
||||
pytest.param("", 5, "", id="empty-string"),
|
||||
],
|
||||
)
|
||||
def test_truncates(self, text: str, max_length: int, expected: str) -> None:
|
||||
"""Truncate text that exceeds the maximum length."""
|
||||
assert truncate(text, max_length) == expected
|
||||
|
||||
|
||||
class TestEscapeMarkdown:
|
||||
"""Markdown special character escaping."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_text", "expected"),
|
||||
[
|
||||
pytest.param("hello", "hello", id="plain-text-unchanged"),
|
||||
pytest.param("*bold*", "\\*bold\\*", id="asterisks"),
|
||||
pytest.param("_italic_", "\\_italic\\_", id="underscores"),
|
||||
pytest.param("[link](url)", "\\[link\\]\\(url\\)", id="link-syntax"),
|
||||
pytest.param("`code`", "\\`code\\`", id="backticks"),
|
||||
pytest.param("# heading", "\\# heading", id="heading"),
|
||||
pytest.param("> quote", "\\> quote", id="blockquote"),
|
||||
pytest.param("<html>", "\\<html\\>", id="angle-brackets"),
|
||||
pytest.param("a & b", "a \\& b", id="ampersand"),
|
||||
pytest.param("a\\b", "a\\\\b", id="backslash"),
|
||||
pytest.param("", "", id="empty-string"),
|
||||
],
|
||||
)
|
||||
def test_escapes_special_chars(self, input_text: str, expected: str) -> None:
|
||||
"""Escape the given Markdown special character."""
|
||||
assert escape_markdown(input_text) == expected
|
||||
|
||||
|
||||
class TestSanitizeForPlainText:
|
||||
"""Plain text sanitization for notifications."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_text", "expected"),
|
||||
[
|
||||
pytest.param("hello world", "hello world", id="plain-text"),
|
||||
pytest.param("line1\nline2", "line1 line2", id="newline-removed"),
|
||||
pytest.param("line1\rline2", "line1 line2", id="carriage-return"),
|
||||
pytest.param("line1\r\nline2", "line1 line2", id="crlf-removed"),
|
||||
pytest.param(
|
||||
"too many spaces", "too many spaces", id="spaces-collapsed"
|
||||
),
|
||||
pytest.param("", "", id="empty-string"),
|
||||
],
|
||||
)
|
||||
def test_sanitizes(self, input_text: str, expected: str) -> None:
|
||||
"""Sanitize the text for safe plain-text rendering."""
|
||||
assert sanitize_for_plain_text(input_text) == expected
|
||||
|
||||
|
||||
class TestSanitizeForMarkdown:
|
||||
"""Markdown sanitization combining newline removal and escaping."""
|
||||
|
||||
def test_removes_newlines_and_escapes(self) -> None:
|
||||
"""Remove newlines and escape Markdown special characters."""
|
||||
result = sanitize_for_markdown("*bold*\nnew line")
|
||||
assert result == "\\*bold\\* new line"
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
"""Return empty string unchanged."""
|
||||
assert sanitize_for_markdown("") == ""
|
||||
@@ -62,6 +62,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aioresponses"
|
||||
version = "0.7.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/03/532bbc645bdebcf3b6af3b25d46655259d66ce69abba7720b71ebfabbade/aioresponses-0.7.8.tar.gz", hash = "sha256:b861cdfe5dc58f3b8afac7b0a6973d5d7b2cb608dd0f6253d16b8ee8eaf6df11", size = 40253, upload-time = "2025-01-19T18:14:03.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b7/584157e43c98aa89810bc2f7099e7e01c728ecf905a66cf705106009228f/aioresponses-0.7.8-py2.py3-none-any.whl", hash = "sha256:b73bd4400d978855e55004b23a3a84cb0f018183bcf066a85ad392800b5b9a94", size = 12518, upload-time = "2025-01-19T18:13:59.633Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.4.0"
|
||||
@@ -306,6 +319,45 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/92/dfd892312d822f36c55366118b95d914e5f16de11044a27cf10a7d71bbbf/commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9", size = 51068, upload-time = "2019-10-04T15:37:37.674Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.5"
|
||||
@@ -534,6 +586,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jaraco-classes"
|
||||
version = "3.4.0"
|
||||
@@ -863,29 +924,37 @@ wheels = [
|
||||
[[package]]
|
||||
name = "owncastsentry"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "maubot", extra = ["encryption"] },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "aioresponses" },
|
||||
{ name = "codespell" },
|
||||
{ name = "hatch" },
|
||||
{ name = "maubot", extra = ["encryption"] },
|
||||
{ name = "mypy" },
|
||||
{ name = "pip-audit" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
{ name = "time-machine" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "maubot", extras = ["encryption"] }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "aioresponses", specifier = ">=0.7.8" },
|
||||
{ name = "codespell", specifier = ">=2.4.2" },
|
||||
{ name = "hatch", specifier = ">=1.16.5" },
|
||||
{ name = "maubot", extras = ["encryption"], specifier = ">=0.6.0" },
|
||||
{ name = "mypy", specifier = ">=1.19.1" },
|
||||
{ name = "pip-audit", specifier = ">=2.10.0" },
|
||||
{ name = "pytest", specifier = ">=9.0.2" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||
{ name = "pytest-cov", specifier = ">=7.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.15.5" },
|
||||
{ name = "time-machine", specifier = ">=3.2.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1127,6 +1196,48 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-discovery"
|
||||
version = "1.1.3"
|
||||
@@ -1293,6 +1404,36 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-machine"
|
||||
version = "3.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/fc/37b02f6094dbb1f851145330460532176ed2f1dc70511a35828166c41e52/time_machine-3.2.0.tar.gz", hash = "sha256:a4ddd1cea17b8950e462d1805a42b20c81eb9aafc8f66b392dd5ce997e037d79", size = 14804, upload-time = "2025-12-17T23:33:02.599Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/70/b4b980d126ed155c78d1879c50d60c8dcbd47bd11cb14ee7be50e0dfc07f/time_machine-3.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1398980c017fe5744d66f419e0115ee48a53b00b146d738e1416c225eb610b82", size = 19303, upload-time = "2025-12-17T23:32:35.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/73/eaa33603c69a68fe2b6f54f9dd75481693d62f1d29676531002be06e2d1c/time_machine-3.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4f8f4e35f4191ef70c2ab8ff490761ee9051b891afce2bf86dde3918eb7b537b", size = 15431, upload-time = "2025-12-17T23:32:37.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/10/b81e138e86cc7bab40cdb59d294b341e172201f4a6c84bb0ec080407977a/time_machine-3.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6db498686ecf6163c5aa8cf0bcd57bbe0f4081184f247edf3ee49a2612b584f9", size = 33206, upload-time = "2025-12-17T23:32:38.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/72/4deab446b579e8bd5dca91de98595c5d6bd6a17ce162abf5c5f2ce40d3d8/time_machine-3.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:027c1807efb74d0cd58ad16524dec94212fbe900115d70b0123399883657ac0f", size = 34792, upload-time = "2025-12-17T23:32:40.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/39/439c6b587ddee76d533fe972289d0646e0a5520e14dc83d0a30aeb5565f7/time_machine-3.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92432610c05676edd5e6946a073c6f0c926923123ce7caee1018dc10782c713d", size = 36187, upload-time = "2025-12-17T23:32:41.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/db/2da4368db15180989bab83746a857bde05ad16e78f326801c142bb747a06/time_machine-3.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c25586b62480eb77ef3d953fba273209478e1ef49654592cd6a52a68dfe56a67", size = 34855, upload-time = "2025-12-17T23:32:42.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/84/120a431fee50bc4c241425bee4d3a4910df4923b7ab5f7dff1bf0c772f08/time_machine-3.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6bf3a2fa738d15e0b95d14469a0b8ea42635467408d8b490e263d5d45c9a177f", size = 33222, upload-time = "2025-12-17T23:32:43.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/ea/89cfda82bb8c57ff91bb9a26751aa234d6d90e9b4d5ab0ad9dce0f9f0329/time_machine-3.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ce76b82276d7ad2a66cdc85dad4df19d1422b69183170a34e8fbc4c3f35502f7", size = 34270, upload-time = "2025-12-17T23:32:45.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/aa/235357da4f69a51a8d35fcbfcfa77cdc7dc24f62ae54025006570bda7e2d/time_machine-3.2.0-cp314-cp314-win32.whl", hash = "sha256:14d6778273c543441863dff712cd1d7803dee946b18de35921eb8df10714539d", size = 17544, upload-time = "2025-12-17T23:32:46.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/51/6c8405a7276be79693b792cff22ce41067ec05db26a7d02f2d5b06324434/time_machine-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbf821da96dbc80d349fa9e7c36e670b41d68a878d28c8850057992fed430eef", size = 18423, upload-time = "2025-12-17T23:32:47.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/03/a3cf419e20c35fc203c6e4fed48b5b667c1a2b4da456d9971e605f73ecef/time_machine-3.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:71c75d71f8e68abc8b669bca26ed2ddd558430a6c171e32b8620288565f18c0e", size = 17050, upload-time = "2025-12-17T23:32:48.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a1/142de946dc4393f910bf4564b5c3ba819906e1f49b06c9cb557519c849e4/time_machine-3.2.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4e374779021446fc2b5c29d80457ec9a3b1a5df043dc2aae07d7c1415d52323c", size = 19991, upload-time = "2025-12-17T23:32:49.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/62/7f17def6289901f94726921811a16b9adce46e666362c75d45730c60274f/time_machine-3.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:122310a6af9c36e9a636da32830e591e7923e8a07bdd0a43276c3a36c6821c90", size = 15707, upload-time = "2025-12-17T23:32:50.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/d3/3502fb9bd3acb159c18844b26c43220201a0d4a622c0c853785d07699a92/time_machine-3.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba3eeb0f018cc362dd8128befa3426696a2e16dd223c3fb695fde184892d4d8c", size = 39207, upload-time = "2025-12-17T23:32:52.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/be/8b27f4aa296fda14a5a2ad7f588ddd450603c33415ab3f8e85b2f1a44678/time_machine-3.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:77d38ba664b381a7793f8786efc13b5004f0d5f672dae814430445b8202a67a6", size = 40764, upload-time = "2025-12-17T23:32:53.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/cd/fe4c4e5c8ab6d48fab3624c32be9116fb120173a35fe67e482e5cf68b3d2/time_machine-3.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f09abeb8f03f044d72712207e0489a62098ad3ad16dac38927fcf80baca4d6a7", size = 43508, upload-time = "2025-12-17T23:32:54.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/28/5a3ba2fce85b97655a425d6bb20a441550acd2b304c96b2c19d3839f721a/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6b28367ce4f73987a55e230e1d30a57a3af85da8eb1a140074eb6e8c7e6ef19f", size = 41712, upload-time = "2025-12-17T23:32:55.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/58/e38084be7fdabb4835db68a3a47e58c34182d79fc35df1ecbe0db2c5359f/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:903c7751c904581da9f7861c3015bed7cdc40047321291d3694a3cdc783bbca3", size = 38939, upload-time = "2025-12-17T23:32:56.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/d0/ad3feb0a392ef4e0c08bc32024950373ddc0669002cbdcbb9f3bf0c2d114/time_machine-3.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:528217cad85ede5f85c8bc78b0341868d3c3cfefc6ecb5b622e1cacb6c73247b", size = 39837, upload-time = "2025-12-17T23:32:58.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/9e/5f4b2ea63b267bd78f3245e76f5528836611b5f2d30b5e7300a722fe4428/time_machine-3.2.0-cp314-cp314t-win32.whl", hash = "sha256:75724762ffd517e7e80aaec1fad1ff5a7414bd84e2b3ee7a0bacfeb67c14926e", size = 18091, upload-time = "2025-12-17T23:32:59.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/6f/456b1f4d2700ae02b19eba830f870596a4b89b74bac3b6c80666f1b108c5/time_machine-3.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2526abbd053c5bca898d1b3e7898eec34626b12206718d8c7ce88fd12c1c9c5c", size = 19208, upload-time = "2025-12-17T23:33:00.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/22/8063101427ecd3d2652aada4d21d0876b07a3dc789125bca2ee858fec3ed/time_machine-3.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7f2fb6784b414edbe2c0b558bfaab0c251955ba27edd62946cce4a01675a992c", size = 17359, upload-time = "2025-12-17T23:33:01.54Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.0"
|
||||
|
||||
Reference in New Issue
Block a user