Added owlbot.testing package with pytest plugin, event factories, and recording Owncast stubs for module tests.
Audit / Dependencies (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 19s
CI / Tests (Python 3.13) (push) Successful in 19s
CI / Tests (Python 3.14) (push) Successful in 16s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 9s
Audit / Dependencies (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 19s
CI / Tests (Python 3.13) (push) Successful in 19s
CI / Tests (Python 3.14) (push) Successful in 16s
CI / Type Checking (push) Successful in 11s
CI / Spelling (push) Successful in 9s
- owlbot.testing.plugin: fixtures that mirror ModuleLoader's wiring (module_context, module_lifecycle, command/event/route dispatchers, in-memory storage, real Config on a tmp YAML, HttpClient with aioresponses interception, and an aiohttp route_client). - owlbot.testing.helpers: make_user, make_chat_event, and factories for every Owncast event type, with sensible defaults. - owlbot.testing.stubs: RecordingOwncastClient and RecordingOwncastAdminClient that log each call to a .calls list in place of hitting the API. - pyproject.toml: added a 'testing' extra for downstream module authors (pytest, pytest-asyncio, pytest-aiohttp, aioresponses). - tests: migrated in-tree tests to the new helpers, moved module tests under tests/builtin_modules/, and swapped freezegun for time-machine.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# 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.
|
||||
|
||||
"""Testing utilities for Owlbot modules."""
|
||||
|
||||
from owlbot.testing.helpers import (
|
||||
make_chat_event,
|
||||
make_name_changed_event,
|
||||
make_stream_started_event,
|
||||
make_stream_status,
|
||||
make_stream_stopped_event,
|
||||
make_stream_title_updated_event,
|
||||
make_user,
|
||||
make_user_joined_event,
|
||||
make_user_parted_event,
|
||||
make_visibility_update_event,
|
||||
)
|
||||
from owlbot.testing.stubs import (
|
||||
RecordedCall,
|
||||
RecordingOwncastAdminClient,
|
||||
RecordingOwncastClient,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RecordedCall",
|
||||
"RecordingOwncastAdminClient",
|
||||
"RecordingOwncastClient",
|
||||
"make_chat_event",
|
||||
"make_name_changed_event",
|
||||
"make_stream_started_event",
|
||||
"make_stream_status",
|
||||
"make_stream_stopped_event",
|
||||
"make_stream_title_updated_event",
|
||||
"make_user",
|
||||
"make_user_joined_event",
|
||||
"make_user_parted_event",
|
||||
"make_visibility_update_event",
|
||||
]
|
||||
@@ -0,0 +1,330 @@
|
||||
# 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.
|
||||
|
||||
"""Factory functions for building test event and user objects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from owlbot.api.event_types import (
|
||||
ChatEvent,
|
||||
NameChangedEvent,
|
||||
StreamStartedEvent,
|
||||
StreamStatus,
|
||||
StreamStoppedEvent,
|
||||
StreamTitleUpdatedEvent,
|
||||
User,
|
||||
UserJoinedEvent,
|
||||
UserPartedEvent,
|
||||
VisibilityUpdateEvent,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def make_user(
|
||||
*,
|
||||
id: str = "test-user-id", # noqa: A002
|
||||
display_name: str = "TestUser",
|
||||
display_color: int = 0,
|
||||
created_at: datetime | None = None,
|
||||
previous_names: list[str] | None = None,
|
||||
name_changed_at: datetime | None = None,
|
||||
is_bot: bool = False,
|
||||
is_authenticated: bool = False,
|
||||
is_moderator: bool = False,
|
||||
scopes: frozenset[str] | None = None,
|
||||
) -> User:
|
||||
"""Build a User with sensible test defaults.
|
||||
|
||||
:param id: User identifier.
|
||||
:param display_name: Display name shown in chat.
|
||||
:param display_color: Color index for the user's name.
|
||||
:param created_at: Account creation timestamp.
|
||||
:param previous_names: List of previous display names.
|
||||
:param name_changed_at: Timestamp of last name change.
|
||||
:param is_bot: Whether the user is a bot.
|
||||
:param is_authenticated: Whether the user is authenticated.
|
||||
:param is_moderator: When True, adds ``"MODERATOR"`` to scopes.
|
||||
:param scopes: Scope set for the user; defaults to empty.
|
||||
:return: A populated User instance.
|
||||
"""
|
||||
resolved_scopes = scopes if scopes is not None else frozenset()
|
||||
if is_moderator:
|
||||
resolved_scopes = resolved_scopes | frozenset({"MODERATOR"})
|
||||
|
||||
return User(
|
||||
id=id,
|
||||
display_name=display_name,
|
||||
display_color=display_color,
|
||||
created_at=created_at,
|
||||
previous_names=previous_names if previous_names is not None else [],
|
||||
name_changed_at=name_changed_at,
|
||||
is_bot=is_bot,
|
||||
is_authenticated=is_authenticated,
|
||||
scopes=resolved_scopes,
|
||||
)
|
||||
|
||||
|
||||
def make_chat_event(
|
||||
*,
|
||||
user: User | None = None,
|
||||
raw_body: str = "test message",
|
||||
body: str | None = None,
|
||||
client_id: int = 0,
|
||||
message_id: str = "test-msg-id",
|
||||
is_visible: bool = True,
|
||||
timestamp: datetime | None = None,
|
||||
) -> ChatEvent:
|
||||
"""Build a ChatEvent with sensible test defaults.
|
||||
|
||||
``raw_body`` is the primary field because it is what the command
|
||||
dispatcher parses; tests dispatching commands should set it to the
|
||||
command text. ``body`` defaults to a literal copy of ``raw_body``. It
|
||||
is **not** Owncast-rendered HTML, so tests that need the rendered form
|
||||
must pass ``body=`` explicitly.
|
||||
|
||||
:param user: The chat user; defaults to ``make_user()``.
|
||||
:param raw_body: Original user input; also used as the body default.
|
||||
:param body: Rendered message body; defaults to *raw_body*.
|
||||
:param client_id: Client connection identifier.
|
||||
:param message_id: Unique message identifier.
|
||||
:param is_visible: Whether the message is visible. Defaults to True;
|
||||
chat messages are normally shown.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated ChatEvent instance.
|
||||
"""
|
||||
return ChatEvent(
|
||||
user=user if user is not None else make_user(),
|
||||
body=body if body is not None else raw_body,
|
||||
raw_body=raw_body,
|
||||
client_id=client_id,
|
||||
message_id=message_id,
|
||||
is_visible=is_visible,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def make_user_joined_event(
|
||||
*,
|
||||
user: User | None = None,
|
||||
client_id: int = 0,
|
||||
event_id: str = "test-join-id",
|
||||
timestamp: datetime | None = None,
|
||||
) -> UserJoinedEvent:
|
||||
"""Build a UserJoinedEvent with sensible test defaults.
|
||||
|
||||
:param user: The joining user; defaults to ``make_user()``.
|
||||
:param client_id: Client connection identifier.
|
||||
:param event_id: Unique event identifier.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated UserJoinedEvent instance.
|
||||
"""
|
||||
return UserJoinedEvent(
|
||||
user=user if user is not None else make_user(),
|
||||
client_id=client_id,
|
||||
event_id=event_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def make_user_parted_event(
|
||||
*,
|
||||
user: User | None = None,
|
||||
client_id: int = 0,
|
||||
event_id: str = "test-part-id",
|
||||
timestamp: datetime | None = None,
|
||||
) -> UserPartedEvent:
|
||||
"""Build a UserPartedEvent with sensible test defaults.
|
||||
|
||||
:param user: The departing user; defaults to ``make_user()``.
|
||||
:param client_id: Client connection identifier.
|
||||
:param event_id: Unique event identifier.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated UserPartedEvent instance.
|
||||
"""
|
||||
return UserPartedEvent(
|
||||
user=user if user is not None else make_user(),
|
||||
client_id=client_id,
|
||||
event_id=event_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def make_name_changed_event(
|
||||
*,
|
||||
user: User | None = None,
|
||||
client_id: int = 0,
|
||||
new_name: str = "NewName",
|
||||
event_id: str = "test-name-change-id",
|
||||
timestamp: datetime | None = None,
|
||||
) -> NameChangedEvent:
|
||||
"""Build a NameChangedEvent with sensible test defaults.
|
||||
|
||||
:param user: The user changing names; defaults to ``make_user()``.
|
||||
:param client_id: Client connection identifier.
|
||||
:param new_name: The new display name.
|
||||
:param event_id: Unique event identifier.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated NameChangedEvent instance.
|
||||
"""
|
||||
return NameChangedEvent(
|
||||
user=user if user is not None else make_user(),
|
||||
client_id=client_id,
|
||||
new_name=new_name,
|
||||
event_id=event_id,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def make_stream_started_event(
|
||||
*,
|
||||
server_id: str = "test-server-id",
|
||||
server_name: str = "Test Server",
|
||||
stream_title: str = "Test Stream",
|
||||
summary: str = "",
|
||||
timestamp: datetime | None = None,
|
||||
) -> StreamStartedEvent:
|
||||
"""Build a StreamStartedEvent with sensible test defaults.
|
||||
|
||||
:param server_id: Server identifier.
|
||||
:param server_name: Server display name.
|
||||
:param stream_title: Current stream title.
|
||||
:param summary: Stream summary text.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated StreamStartedEvent instance.
|
||||
"""
|
||||
return StreamStartedEvent(
|
||||
server_id=server_id,
|
||||
server_name=server_name,
|
||||
stream_title=stream_title,
|
||||
summary=summary,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def make_stream_stopped_event(
|
||||
*,
|
||||
server_id: str = "test-server-id",
|
||||
server_name: str = "Test Server",
|
||||
stream_title: str = "Test Stream",
|
||||
summary: str = "",
|
||||
timestamp: datetime | None = None,
|
||||
) -> StreamStoppedEvent:
|
||||
"""Build a StreamStoppedEvent with sensible test defaults.
|
||||
|
||||
:param server_id: Server identifier.
|
||||
:param server_name: Server display name.
|
||||
:param stream_title: Current stream title.
|
||||
:param summary: Stream summary text.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated StreamStoppedEvent instance.
|
||||
"""
|
||||
return StreamStoppedEvent(
|
||||
server_id=server_id,
|
||||
server_name=server_name,
|
||||
stream_title=stream_title,
|
||||
summary=summary,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def make_stream_status(
|
||||
*,
|
||||
last_connect_time: datetime | None = None,
|
||||
last_disconnect_time: datetime | None = None,
|
||||
version_number: str = "",
|
||||
stream_title: str = "Test Stream",
|
||||
viewer_count: int = 0,
|
||||
overall_max_viewer_count: int = 0,
|
||||
session_max_viewer_count: int = 0,
|
||||
is_online: bool = False,
|
||||
) -> StreamStatus:
|
||||
"""Build a StreamStatus with sensible test defaults.
|
||||
|
||||
:param last_connect_time: Timestamp of the last stream connect.
|
||||
:param last_disconnect_time: Timestamp of the last stream disconnect.
|
||||
:param version_number: Owncast server version string.
|
||||
:param stream_title: Current stream title.
|
||||
:param viewer_count: Current viewer count.
|
||||
:param overall_max_viewer_count: All-time peak viewer count.
|
||||
:param session_max_viewer_count: Current-session peak viewer count.
|
||||
:param is_online: Whether the stream is currently live.
|
||||
:return: A populated StreamStatus instance.
|
||||
"""
|
||||
return StreamStatus(
|
||||
last_connect_time=last_connect_time,
|
||||
last_disconnect_time=last_disconnect_time,
|
||||
version_number=version_number,
|
||||
stream_title=stream_title,
|
||||
viewer_count=viewer_count,
|
||||
overall_max_viewer_count=overall_max_viewer_count,
|
||||
session_max_viewer_count=session_max_viewer_count,
|
||||
is_online=is_online,
|
||||
)
|
||||
|
||||
|
||||
def make_stream_title_updated_event(
|
||||
*,
|
||||
server_id: str = "test-server-id",
|
||||
server_name: str = "Test Server",
|
||||
stream_title: str = "Test Stream",
|
||||
summary: str = "",
|
||||
status: StreamStatus | None = None,
|
||||
timestamp: datetime | None = None,
|
||||
) -> StreamTitleUpdatedEvent:
|
||||
"""Build a StreamTitleUpdatedEvent with sensible test defaults.
|
||||
|
||||
:param server_id: Server identifier.
|
||||
:param server_name: Server display name.
|
||||
:param stream_title: Current stream title.
|
||||
:param summary: Stream summary text.
|
||||
:param status: Optional stream status snapshot.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated StreamTitleUpdatedEvent instance.
|
||||
"""
|
||||
return StreamTitleUpdatedEvent(
|
||||
server_id=server_id,
|
||||
server_name=server_name,
|
||||
stream_title=stream_title,
|
||||
summary=summary,
|
||||
status=status,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def make_visibility_update_event(
|
||||
*,
|
||||
event_id: str = "test-vis-id",
|
||||
message_ids: list[str] | None = None,
|
||||
is_visible: bool = False,
|
||||
timestamp: datetime | None = None,
|
||||
) -> VisibilityUpdateEvent:
|
||||
"""Build a VisibilityUpdateEvent with sensible test defaults.
|
||||
|
||||
:param event_id: Unique event identifier.
|
||||
:param message_ids: List of affected message IDs.
|
||||
:param is_visible: Whether the messages are now visible. Defaults to
|
||||
False; visibility updates are primarily used to hide messages.
|
||||
:param timestamp: Event timestamp.
|
||||
:return: A populated VisibilityUpdateEvent instance.
|
||||
"""
|
||||
return VisibilityUpdateEvent(
|
||||
event_id=event_id,
|
||||
message_ids=message_ids if message_ids is not None else [],
|
||||
is_visible=is_visible,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
@@ -0,0 +1,459 @@
|
||||
# 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.
|
||||
|
||||
"""Pytest plugin for Owlbot module testing.
|
||||
|
||||
The fixture layering mirrors :class:`~owlbot.module_loader.ModuleLoader`:
|
||||
|
||||
* A shared ``_context_registry`` maps module names to ``ModuleContext``
|
||||
instances (production equivalent: ``ModuleLoader._module_contexts``).
|
||||
* A shared ``_loaded_modules`` set is wired into the dispatchers and
|
||||
mutated as modules are registered (production equivalent:
|
||||
``ModuleLoader.loaded_modules``).
|
||||
* Dispatchers close over the registry for lookups, raising
|
||||
:class:`pytest.UsageError` for unknown names.
|
||||
* ``module_context`` builds the default module's context on demand
|
||||
(mirroring ``ModuleLoader.load_module``) and caches it in the registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from aiohttp import web
|
||||
from aioresponses import aioresponses
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
import owlbot
|
||||
from owlbot.api.config import Config, ModuleConfig
|
||||
from owlbot.api.context import ModuleContext
|
||||
from owlbot.api.http_client import HttpClient
|
||||
from owlbot.api.storage import ModuleStorage
|
||||
from owlbot.api.templates import ModuleTemplates
|
||||
from owlbot.module_loader import ModuleLoader
|
||||
from owlbot.registries.commands import CommandDispatcher, ModuleCommands
|
||||
from owlbot.registries.events import EventDispatcher, ModuleEvents
|
||||
from owlbot.registries.routes import ModuleRoutes, RouteDispatcher
|
||||
|
||||
from .stubs import RecordingOwncastAdminClient, RecordingOwncastClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Callable, Iterator
|
||||
from types import ModuleType
|
||||
|
||||
from aiohttp.test_utils import TestClient
|
||||
|
||||
|
||||
def _make_context_lookup(
|
||||
registry: dict[str, ModuleContext],
|
||||
) -> Callable[[str], ModuleContext]:
|
||||
"""Build a ``get_module_context`` callable that reads from the registry.
|
||||
|
||||
Unknown names raise :class:`pytest.UsageError` with a message pointing
|
||||
the reader at ``module_context`` to correct the setup.
|
||||
"""
|
||||
|
||||
def lookup(name: str) -> ModuleContext:
|
||||
try:
|
||||
return registry[name]
|
||||
except KeyError as exc:
|
||||
msg = (
|
||||
f"No module context registered for {name!r}. Use the "
|
||||
f"module_context fixture to register the default module."
|
||||
)
|
||||
raise pytest.UsageError(msg) from exc
|
||||
|
||||
return lookup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def module_pkg() -> ModuleType | None:
|
||||
"""Package of the Owlbot module under test.
|
||||
|
||||
Override with the imported package (e.g., ``owlbot.builtin_modules.quotes``
|
||||
or ``owlbot_modules.my_module``) to auto-wire the module name, templates,
|
||||
and the ``module_lifecycle`` / ``registered_handlers`` fixtures for that
|
||||
module.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def module_name(module_pkg: ModuleType | None) -> str:
|
||||
"""Canonical name of the default module under test.
|
||||
|
||||
Defaults to the last dotted segment of ``module_pkg.__name__``, or
|
||||
``"test_module"`` when ``module_pkg`` is unset. Override this fixture
|
||||
directly to set a specific name without importing a package.
|
||||
"""
|
||||
if module_pkg is None:
|
||||
return "test_module"
|
||||
return module_pkg.__name__.rsplit(".", 1)[-1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client_enabled() -> bool:
|
||||
"""Whether the admin client is wired into the module context."""
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def command_prefix() -> str:
|
||||
"""Prefix character used for command parsing."""
|
||||
return "!"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler_timeout() -> float:
|
||||
"""Timeout in seconds for handler execution."""
|
||||
return 30.0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_data() -> dict[str, Any]:
|
||||
"""Seed YAML content for the real :class:`~owlbot.api.config.Config`.
|
||||
|
||||
Override to customize any section (e.g. set ``owlbot.public_base_url``
|
||||
or a ``modules.<name>`` block) before ``Config`` loads.
|
||||
"""
|
||||
return {
|
||||
"owncast": {"url": "http://localhost:8080"},
|
||||
"owlbot": {"public_base_url": "http://localhost:8081"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_path(tmp_path: Path, config_data: dict[str, Any]) -> Path:
|
||||
"""Write ``config_data`` to ``tmp_path/config.yaml`` and return its path."""
|
||||
path = tmp_path / "config.yaml"
|
||||
yaml = YAML(typ="safe")
|
||||
with path.open("w") as f:
|
||||
yaml.dump(config_data, f)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config(config_path: Path, monkeypatch: pytest.MonkeyPatch) -> Config:
|
||||
"""Construct a real ``Config`` backed by a tmp YAML file.
|
||||
|
||||
``OWLBOT_*`` environment variables are cleared for the duration of the
|
||||
test so CI-level overrides do not bleed into the fixture.
|
||||
"""
|
||||
for name in list(os.environ):
|
||||
if name.startswith("OWLBOT_"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
return Config(config_path)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def storage(module_name: str) -> AsyncIterator[ModuleStorage]:
|
||||
"""Yield an open in-memory ModuleStorage for the default module."""
|
||||
async with ModuleStorage(None, module_name) as s:
|
||||
yield s
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def owncast_client(config: Config) -> RecordingOwncastClient:
|
||||
"""Return a recording stub for the Owncast client.
|
||||
|
||||
The stub's ``base_url`` is seeded from ``config.owncast_url`` so it stays
|
||||
consistent with any ``config_data`` override the test sets.
|
||||
"""
|
||||
return RecordingOwncastClient(base_url=config.owncast_url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(
|
||||
config: Config,
|
||||
*,
|
||||
admin_client_enabled: bool,
|
||||
) -> RecordingOwncastAdminClient:
|
||||
"""Return a recording stub for the Owncast admin client.
|
||||
|
||||
The stub's ``base_url`` is seeded from ``config.owncast_url`` so it stays
|
||||
consistent with any ``config_data`` override the test sets.
|
||||
|
||||
:raises pytest.UsageError: If ``admin_client_enabled`` has not been
|
||||
overridden to ``True``. Prevents tests from asserting on a
|
||||
disconnected stub that handlers never see.
|
||||
"""
|
||||
if not admin_client_enabled:
|
||||
raise pytest.UsageError(
|
||||
"admin_client requires admin_client_enabled=True; override the "
|
||||
"admin_client_enabled fixture to enable the admin client"
|
||||
)
|
||||
return RecordingOwncastAdminClient(base_url=config.owncast_url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def module_config(config: Config, module_name: str) -> ModuleConfig:
|
||||
"""Return a real ``ModuleConfig`` scoped to ``module_name``."""
|
||||
return ModuleConfig(config, module_name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def templates(module_pkg: ModuleType | None, tmp_path: Path) -> ModuleTemplates:
|
||||
"""Return a Jinja2 ``ModuleTemplates`` renderer for the default module.
|
||||
|
||||
Derives from ``module_pkg`` when set (module templates + core). When unset,
|
||||
falls back to a core-only renderer (backed by an empty ``tmp_path``) so
|
||||
module-specific templates resolve to ``TemplateNotFound`` rather than
|
||||
``AttributeError``.
|
||||
"""
|
||||
core_dir = Path(owlbot.__file__).parent / "templates"
|
||||
if module_pkg is None:
|
||||
return ModuleTemplates(tmp_path, core_dir)
|
||||
if module_pkg.__file__ is None:
|
||||
msg = f"module package {module_pkg.__name__!r} has no __file__"
|
||||
raise RuntimeError(msg)
|
||||
return ModuleTemplates(Path(module_pkg.__file__).parent, core_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mocked_http() -> Iterator[aioresponses]:
|
||||
"""Yield an ``aioresponses`` controller intercepting aiohttp requests.
|
||||
|
||||
Active for every test that uses the ``http`` fixture (directly or via
|
||||
``module_context``). Register canned responses on the yielded object to
|
||||
simulate external APIs. Requests to unregistered URLs raise
|
||||
``ConnectionError``, so tests cannot accidentally reach the network.
|
||||
|
||||
Loopback addresses (``127.0.0.1`` and ``localhost``) pass through
|
||||
unmocked so the ``route_client`` fixture can reach its own aiohttp
|
||||
test server.
|
||||
"""
|
||||
with aioresponses(passthrough=["http://127.0.0.1", "http://localhost"]) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def http(mocked_http: aioresponses) -> AsyncIterator[HttpClient]:
|
||||
"""Yield a started :class:`HttpClient`.
|
||||
|
||||
Depends on ``mocked_http`` so every request is intercepted by the
|
||||
aioresponses patch. The underlying aiohttp session is closed when the
|
||||
test finishes.
|
||||
"""
|
||||
client = HttpClient()
|
||||
await client._start() # noqa: SLF001 # framework lifecycle reused for tests
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client._close() # noqa: SLF001 # framework lifecycle reused for tests
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _context_registry() -> dict[str, ModuleContext]:
|
||||
"""Shared cache of ModuleContexts keyed by module name.
|
||||
|
||||
Mirrors :attr:`owlbot.module_loader.ModuleLoader._module_contexts`. The
|
||||
dispatcher fixtures close over this dict for lookups, and
|
||||
``module_context`` populates it on demand.
|
||||
"""
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _loaded_modules() -> set[str]:
|
||||
"""Shared set of module names currently loaded.
|
||||
|
||||
Mirrors :attr:`owlbot.module_loader.ModuleLoader.loaded_modules`. Wired
|
||||
into ``command_dispatcher`` and mutated by ``module_context`` and
|
||||
``registered_handlers`` as modules are registered.
|
||||
"""
|
||||
return set()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def command_dispatcher(
|
||||
_context_registry: dict[str, ModuleContext],
|
||||
_loaded_modules: set[str],
|
||||
owncast_client: RecordingOwncastClient,
|
||||
handler_timeout: float,
|
||||
command_prefix: str,
|
||||
) -> CommandDispatcher:
|
||||
"""Build a CommandDispatcher that resolves contexts via ``_context_registry``."""
|
||||
return CommandDispatcher(
|
||||
get_module_context=_make_context_lookup(_context_registry),
|
||||
owncast_client=owncast_client,
|
||||
handler_timeout=handler_timeout,
|
||||
loaded_modules=_loaded_modules,
|
||||
command_prefix=command_prefix,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_dispatcher(
|
||||
_context_registry: dict[str, ModuleContext],
|
||||
command_dispatcher: CommandDispatcher,
|
||||
handler_timeout: float,
|
||||
) -> EventDispatcher:
|
||||
"""Build an EventDispatcher that resolves contexts via ``_context_registry``."""
|
||||
return EventDispatcher(
|
||||
command_dispatch=command_dispatcher.dispatch,
|
||||
get_module_context=_make_context_lookup(_context_registry),
|
||||
handler_timeout=handler_timeout,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def route_dispatcher(
|
||||
_context_registry: dict[str, ModuleContext],
|
||||
handler_timeout: float,
|
||||
) -> RouteDispatcher:
|
||||
"""Build a RouteDispatcher that resolves contexts via ``_context_registry``."""
|
||||
return RouteDispatcher(
|
||||
get_module_context=_make_context_lookup(_context_registry),
|
||||
handler_timeout=handler_timeout,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def module_context(
|
||||
_context_registry: dict[str, ModuleContext],
|
||||
_loaded_modules: set[str],
|
||||
module_name: str,
|
||||
command_dispatcher: CommandDispatcher,
|
||||
event_dispatcher: EventDispatcher,
|
||||
route_dispatcher: RouteDispatcher,
|
||||
storage: ModuleStorage,
|
||||
owncast_client: RecordingOwncastClient,
|
||||
module_config: ModuleConfig,
|
||||
templates: ModuleTemplates,
|
||||
http: HttpClient,
|
||||
request: pytest.FixtureRequest,
|
||||
*,
|
||||
admin_client_enabled: bool,
|
||||
) -> ModuleContext:
|
||||
"""Return the ModuleContext for the default module.
|
||||
|
||||
Mirrors the context-building half of
|
||||
:meth:`owlbot.module_loader.ModuleLoader.load_module`: wires the
|
||||
name-scoped ``ModuleCommands`` / ``ModuleEvents`` / ``ModuleRoutes``
|
||||
against the dispatchers, registers the context in ``_context_registry``,
|
||||
and adds the name to ``_loaded_modules``.
|
||||
"""
|
||||
admin_client = (
|
||||
request.getfixturevalue("admin_client") if admin_client_enabled else None
|
||||
)
|
||||
ctx = ModuleContext(
|
||||
module_name=module_name,
|
||||
config=module_config,
|
||||
owncast_client=owncast_client,
|
||||
storage=storage,
|
||||
commands=ModuleCommands(command_dispatcher, module_name),
|
||||
events=ModuleEvents(event_dispatcher, module_name),
|
||||
routes=ModuleRoutes(
|
||||
route_dispatcher, module_name, module_config.public_base_url
|
||||
),
|
||||
http=http,
|
||||
templates=templates,
|
||||
admin_client=admin_client,
|
||||
)
|
||||
_context_registry[module_name] = ctx
|
||||
_loaded_modules.add(module_name)
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def route_client(
|
||||
route_dispatcher: RouteDispatcher,
|
||||
aiohttp_client: Any,
|
||||
) -> TestClient[Any, Any]:
|
||||
"""Return an aiohttp test client wired to the RouteDispatcher."""
|
||||
app = web.Application()
|
||||
app.router.add_route(
|
||||
"*", "/owlbot/{module_name}/{path:.*}", route_dispatcher.dispatch
|
||||
)
|
||||
app.router.add_route("*", "/owlbot/{module_name}", route_dispatcher.dispatch)
|
||||
|
||||
client: TestClient[Any, Any] = await aiohttp_client(app)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registered_handlers(
|
||||
module_pkg: ModuleType | None,
|
||||
module_name: str,
|
||||
command_dispatcher: CommandDispatcher,
|
||||
event_dispatcher: EventDispatcher,
|
||||
route_dispatcher: RouteDispatcher,
|
||||
_loaded_modules: set[str],
|
||||
) -> None:
|
||||
"""Register all @on_command, @on_event, and @on_route handlers from module_pkg.
|
||||
|
||||
Mirrors the registration half of
|
||||
:meth:`owlbot.module_loader.ModuleLoader.load_module`: the module's name
|
||||
is added to ``_loaded_modules`` after registration so dispatch routes
|
||||
commands correctly.
|
||||
|
||||
Prefer ``module_lifecycle`` unless a test specifically needs to skip
|
||||
``@on_setup`` / ``@on_teardown`` hooks (e.g., when the module's setup has
|
||||
side effects the test cannot tolerate).
|
||||
|
||||
:raises pytest.UsageError: If ``module_pkg`` has not been overridden.
|
||||
"""
|
||||
if module_pkg is None:
|
||||
raise pytest.UsageError(
|
||||
"registered_handlers requires module_pkg to be set; override the "
|
||||
"module_pkg fixture with the module package under test"
|
||||
)
|
||||
command_dispatcher.register_from_module(module_pkg, module_name)
|
||||
event_dispatcher.register_from_module(module_pkg, module_name)
|
||||
route_dispatcher.register_from_module(module_pkg, module_name)
|
||||
_loaded_modules.add(module_name)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def module_lifecycle(
|
||||
module_pkg: ModuleType | None,
|
||||
module_context: ModuleContext,
|
||||
registered_handlers: None, # transitively wires decorator registration
|
||||
) -> AsyncIterator[None]:
|
||||
"""Load ``module_pkg`` the way ``ModuleLoader`` does at runtime.
|
||||
|
||||
Depends on ``registered_handlers`` to wire ``@on_command``, ``@on_event``,
|
||||
and ``@on_route`` decorators, runs every ``@on_setup`` hook, yields, then
|
||||
runs every ``@on_teardown`` hook on test exit.
|
||||
|
||||
Recording-stub call logs are preserved across the yield, so tests can
|
||||
observe what ``@on_setup`` did. Tests that want a clean baseline for
|
||||
handler-initiated calls should clear ``owncast_client.calls`` (and
|
||||
``admin_client.calls`` when the admin client is enabled) in a local
|
||||
fixture that depends on this one.
|
||||
|
||||
:raises pytest.UsageError: If ``module_pkg`` has not been overridden.
|
||||
"""
|
||||
if module_pkg is None:
|
||||
raise pytest.UsageError(
|
||||
"module_lifecycle requires module_pkg to be set; override the "
|
||||
"module_pkg fixture with the module package under test"
|
||||
)
|
||||
setup_hooks = ModuleLoader._collect_lifecycle_handlers( # noqa: SLF001 # reuses the real loader's scan
|
||||
module_pkg, "_owlbot_setup"
|
||||
)
|
||||
for hook in setup_hooks:
|
||||
await hook(module_context)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
teardown_hooks = ModuleLoader._collect_lifecycle_handlers( # noqa: SLF001 # reuses the real loader's scan
|
||||
module_pkg, "_owlbot_teardown"
|
||||
)
|
||||
for hook in teardown_hooks:
|
||||
await hook(module_context)
|
||||
@@ -0,0 +1,608 @@
|
||||
# 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.
|
||||
|
||||
"""Recording stubs for the Owncast HTTP clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owlbot.api.owncast_admin_client import (
|
||||
ExternalAction,
|
||||
OwncastAdminClient,
|
||||
SocialHandle,
|
||||
StreamKey,
|
||||
VideoVariant,
|
||||
)
|
||||
from owlbot.api.owncast_client import OwncastClient
|
||||
|
||||
# Stubs inherit from the real classes at type-check time so that code
|
||||
# passing a stub where the real instance is expected (notably plugin.py)
|
||||
# type-checks. At runtime the base is ``object`` to avoid the real
|
||||
# ``__init__`` requiring live HTTP/config dependencies.
|
||||
_ClientBase = OwncastClient
|
||||
_AdminBase = OwncastAdminClient
|
||||
else:
|
||||
_ClientBase = object
|
||||
_AdminBase = object
|
||||
|
||||
|
||||
class RecordedCall(NamedTuple):
|
||||
"""A single recorded method invocation on a recording stub.
|
||||
|
||||
Compares equal to a plain ``(name, kwargs)`` tuple, so assertions written
|
||||
against tuple literals continue to work.
|
||||
"""
|
||||
|
||||
name: str
|
||||
kwargs: dict[str, Any]
|
||||
|
||||
|
||||
class RecordingOwncastClient(_ClientBase):
|
||||
"""Stub mirroring :class:`~owlbot.api.owncast_client.OwncastClient`.
|
||||
|
||||
Each public async method records ``(method_name, kwargs_dict)`` to
|
||||
:attr:`calls` and returns an empty default of the declared return type.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = "http://localhost:8080") -> None:
|
||||
"""Initialize with an empty call log and the given base URL."""
|
||||
self.calls: list[RecordedCall] = []
|
||||
self._base_url = base_url.rstrip("/")
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""The Owncast server base URL passed at construction."""
|
||||
return self._base_url
|
||||
|
||||
async def get_status(self) -> dict[str, Any]:
|
||||
"""Get the public server status."""
|
||||
self.calls.append(RecordedCall("get_status", {}))
|
||||
return {}
|
||||
|
||||
async def send_message(self, body: str, *, unsanitized: bool = False) -> str:
|
||||
"""Send a chat message visible to all viewers."""
|
||||
self.calls.append(
|
||||
RecordedCall("send_message", {"body": body, "unsanitized": unsanitized})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def send_system_message(self, body: str, *, unsanitized: bool = False) -> str:
|
||||
"""Send a system message visible to all viewers."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"send_system_message", {"body": body, "unsanitized": unsanitized}
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def send_action(self, body: str, *, unsanitized: bool = False) -> str:
|
||||
"""Send an action message (like IRC /me)."""
|
||||
self.calls.append(
|
||||
RecordedCall("send_action", {"body": body, "unsanitized": unsanitized})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def send_system_message_to_client(
|
||||
self, client_id: int, body: str, *, unsanitized: bool = False
|
||||
) -> str:
|
||||
"""Send a private system message to a specific viewer."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"send_system_message_to_client",
|
||||
{"client_id": client_id, "body": body, "unsanitized": unsanitized},
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_message_visibility(
|
||||
self, message_ids: list[str], *, visible: bool
|
||||
) -> str:
|
||||
"""Hide or show chat messages (moderation)."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"set_message_visibility",
|
||||
{"message_ids": message_ids, "visible": visible},
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def get_chat_history(self) -> list[dict[str, Any]]:
|
||||
"""Fetch recent chat messages."""
|
||||
self.calls.append(RecordedCall("get_chat_history", {}))
|
||||
return []
|
||||
|
||||
async def get_connected_clients(self) -> list[dict[str, Any]]:
|
||||
"""Get list of currently connected viewers."""
|
||||
self.calls.append(RecordedCall("get_connected_clients", {}))
|
||||
return []
|
||||
|
||||
async def set_stream_title(self, title: str) -> str:
|
||||
"""Update the stream title."""
|
||||
self.calls.append(RecordedCall("set_stream_title", {"title": title}))
|
||||
return ""
|
||||
|
||||
|
||||
class RecordingOwncastAdminClient(_AdminBase):
|
||||
"""Stub mirroring :class:`~owlbot.api.owncast_admin_client.OwncastAdminClient`.
|
||||
|
||||
Each public async method records ``(method_name, kwargs_dict)`` to
|
||||
:attr:`calls` and returns an empty default of the declared return type.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = "http://localhost:8080") -> None:
|
||||
"""Initialize with an empty call log and the given base URL."""
|
||||
self.calls: list[RecordedCall] = []
|
||||
self._base_url = base_url.rstrip("/")
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
"""The Owncast server base URL passed at construction."""
|
||||
return self._base_url
|
||||
|
||||
async def get_status(self) -> dict[str, Any]:
|
||||
"""Get the current server status including stream info and viewer count."""
|
||||
self.calls.append(RecordedCall("get_status", {}))
|
||||
return {}
|
||||
|
||||
async def get_active_viewers(self) -> list[dict[str, Any]]:
|
||||
"""Get a list of currently active viewers."""
|
||||
self.calls.append(RecordedCall("get_active_viewers", {}))
|
||||
return []
|
||||
|
||||
async def get_viewers_over_time(self, window_start: int) -> list[dict[str, Any]]:
|
||||
"""Get viewer count data over time for charting."""
|
||||
self.calls.append(
|
||||
RecordedCall("get_viewers_over_time", {"window_start": window_start})
|
||||
)
|
||||
return []
|
||||
|
||||
async def get_hardware_stats(self) -> dict[str, Any]:
|
||||
"""Get server hardware statistics (CPU, memory, disk)."""
|
||||
self.calls.append(RecordedCall("get_hardware_stats", {}))
|
||||
return {}
|
||||
|
||||
async def get_server_config(self) -> dict[str, Any]:
|
||||
"""Get the full server configuration."""
|
||||
self.calls.append(RecordedCall("get_server_config", {}))
|
||||
return {}
|
||||
|
||||
async def disconnect_stream(self) -> dict[str, Any]:
|
||||
"""Disconnect the current inbound stream."""
|
||||
self.calls.append(RecordedCall("disconnect_stream", {}))
|
||||
return {}
|
||||
|
||||
async def get_playback_metrics(self) -> dict[str, Any]:
|
||||
"""Get playback quality metrics."""
|
||||
self.calls.append(RecordedCall("get_playback_metrics", {}))
|
||||
return {}
|
||||
|
||||
async def get_logs(self) -> list[dict[str, Any]]:
|
||||
"""Get server logs."""
|
||||
self.calls.append(RecordedCall("get_logs", {}))
|
||||
return []
|
||||
|
||||
async def get_warnings(self) -> list[dict[str, Any]]:
|
||||
"""Get server warning and error logs."""
|
||||
self.calls.append(RecordedCall("get_warnings", {}))
|
||||
return []
|
||||
|
||||
async def get_chat_messages(self) -> list[dict[str, Any]]:
|
||||
"""Get chat messages from the admin perspective."""
|
||||
self.calls.append(RecordedCall("get_chat_messages", {}))
|
||||
return []
|
||||
|
||||
async def get_connected_chat_clients(self) -> list[dict[str, Any]]:
|
||||
"""Get currently connected chat clients."""
|
||||
self.calls.append(RecordedCall("get_connected_chat_clients", {}))
|
||||
return []
|
||||
|
||||
async def set_message_visibility(
|
||||
self, message_ids: list[str], *, visible: bool
|
||||
) -> str:
|
||||
"""Hide or show chat messages."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"set_message_visibility",
|
||||
{"message_ids": message_ids, "visible": visible},
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_user_enabled(self, user_id: str, *, enabled: bool) -> str:
|
||||
"""Enable or disable a chat user."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_user_enabled", {"user_id": user_id, "enabled": enabled})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def get_disabled_users(self) -> list[dict[str, Any]]:
|
||||
"""Get a list of disabled chat users."""
|
||||
self.calls.append(RecordedCall("get_disabled_users", {}))
|
||||
return []
|
||||
|
||||
async def set_user_moderator(self, user_id: str, *, is_mod: bool) -> str:
|
||||
"""Grant or revoke moderator status for a user."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_user_moderator", {"user_id": user_id, "is_mod": is_mod})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def get_moderators(self) -> list[dict[str, Any]]:
|
||||
"""Get a list of moderator users."""
|
||||
self.calls.append(RecordedCall("get_moderators", {}))
|
||||
return []
|
||||
|
||||
async def ban_ip_address(self, ip: str) -> str:
|
||||
"""Ban an IP address from chat."""
|
||||
self.calls.append(RecordedCall("ban_ip_address", {"ip": ip}))
|
||||
return ""
|
||||
|
||||
async def unban_ip_address(self, ip: str) -> str:
|
||||
"""Remove an IP address ban."""
|
||||
self.calls.append(RecordedCall("unban_ip_address", {"ip": ip}))
|
||||
return ""
|
||||
|
||||
async def get_ip_address_bans(self) -> list[dict[str, Any]]:
|
||||
"""Get a list of banned IP addresses."""
|
||||
self.calls.append(RecordedCall("get_ip_address_bans", {}))
|
||||
return []
|
||||
|
||||
async def set_stream_title(self, title: str) -> str:
|
||||
"""Set the stream title."""
|
||||
self.calls.append(RecordedCall("set_stream_title", {"title": title}))
|
||||
return ""
|
||||
|
||||
async def set_server_name(self, name: str) -> str:
|
||||
"""Set the server name."""
|
||||
self.calls.append(RecordedCall("set_server_name", {"name": name}))
|
||||
return ""
|
||||
|
||||
async def set_server_summary(self, summary: str) -> str:
|
||||
"""Set the server summary."""
|
||||
self.calls.append(RecordedCall("set_server_summary", {"summary": summary}))
|
||||
return ""
|
||||
|
||||
async def set_welcome_message(self, message: str) -> str:
|
||||
"""Set the welcome message shown to new viewers."""
|
||||
self.calls.append(RecordedCall("set_welcome_message", {"message": message}))
|
||||
return ""
|
||||
|
||||
async def set_offline_message(self, message: str) -> str:
|
||||
"""Set the message shown when the stream is offline."""
|
||||
self.calls.append(RecordedCall("set_offline_message", {"message": message}))
|
||||
return ""
|
||||
|
||||
async def set_page_content(self, content: str) -> str:
|
||||
"""Set the custom page content (HTML/markdown below the player)."""
|
||||
self.calls.append(RecordedCall("set_page_content", {"content": content}))
|
||||
return ""
|
||||
|
||||
async def set_admin_password(self, password: str) -> str:
|
||||
"""Change the admin password."""
|
||||
self.calls.append(RecordedCall("set_admin_password", {"password": password}))
|
||||
return ""
|
||||
|
||||
async def set_server_url(self, url: str) -> str:
|
||||
"""Set the public server URL."""
|
||||
self.calls.append(RecordedCall("set_server_url", {"url": url}))
|
||||
return ""
|
||||
|
||||
async def set_logo(self, base64_data_url: str) -> str:
|
||||
"""Set the server logo from a base64 data URL."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_logo", {"base64_data_url": base64_data_url})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_custom_styles(self, css: str) -> str:
|
||||
"""Set custom CSS styles for the web interface."""
|
||||
self.calls.append(RecordedCall("set_custom_styles", {"css": css}))
|
||||
return ""
|
||||
|
||||
async def set_custom_javascript(self, js: str) -> str:
|
||||
"""Set custom JavaScript for the web interface."""
|
||||
self.calls.append(RecordedCall("set_custom_javascript", {"js": js}))
|
||||
return ""
|
||||
|
||||
async def set_color_variables(self, variables: dict[str, Any]) -> str:
|
||||
"""Set the custom color variables for the web interface."""
|
||||
self.calls.append(RecordedCall("set_color_variables", {"variables": variables}))
|
||||
return ""
|
||||
|
||||
async def set_tags(self, tags: list[str]) -> str:
|
||||
"""Set the server tags."""
|
||||
self.calls.append(RecordedCall("set_tags", {"tags": tags}))
|
||||
return ""
|
||||
|
||||
async def set_social_handles(self, handles: list[SocialHandle]) -> str:
|
||||
"""Set the social media handles displayed on the page."""
|
||||
self.calls.append(RecordedCall("set_social_handles", {"handles": handles}))
|
||||
return ""
|
||||
|
||||
async def set_external_actions(self, actions: list[ExternalAction]) -> str:
|
||||
"""Set the external actions (buttons/links in the player)."""
|
||||
self.calls.append(RecordedCall("set_external_actions", {"actions": actions}))
|
||||
return ""
|
||||
|
||||
async def set_chat_disabled(self, *, disabled: bool) -> str:
|
||||
"""Enable or disable the chat."""
|
||||
self.calls.append(RecordedCall("set_chat_disabled", {"disabled": disabled}))
|
||||
return ""
|
||||
|
||||
async def set_chat_join_messages_enabled(self, *, enabled: bool) -> str:
|
||||
"""Enable or disable chat join messages."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_chat_join_messages_enabled", {"enabled": enabled})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_chat_established_mode(self, *, enabled: bool) -> str:
|
||||
"""Enable or disable established user mode for chat."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_chat_established_mode", {"enabled": enabled})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_chat_spam_protection(self, *, enabled: bool) -> str:
|
||||
"""Enable or disable chat spam protection."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_chat_spam_protection", {"enabled": enabled})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_chat_slur_filter(self, *, enabled: bool) -> str:
|
||||
"""Enable or disable the chat slur filter."""
|
||||
self.calls.append(RecordedCall("set_chat_slur_filter", {"enabled": enabled}))
|
||||
return ""
|
||||
|
||||
async def set_forbidden_usernames(self, names: list[str]) -> str:
|
||||
"""Set the list of forbidden usernames."""
|
||||
self.calls.append(RecordedCall("set_forbidden_usernames", {"names": names}))
|
||||
return ""
|
||||
|
||||
async def set_suggested_usernames(self, names: list[str]) -> str:
|
||||
"""Set the list of suggested usernames for new viewers."""
|
||||
self.calls.append(RecordedCall("set_suggested_usernames", {"names": names}))
|
||||
return ""
|
||||
|
||||
async def set_nsfw(self, *, nsfw: bool) -> str:
|
||||
"""Set the NSFW flag for the server."""
|
||||
self.calls.append(RecordedCall("set_nsfw", {"nsfw": nsfw}))
|
||||
return ""
|
||||
|
||||
async def set_directory_enabled(self, *, enabled: bool) -> str:
|
||||
"""Enable or disable listing in the Owncast directory."""
|
||||
self.calls.append(RecordedCall("set_directory_enabled", {"enabled": enabled}))
|
||||
return ""
|
||||
|
||||
async def set_hide_viewer_count(self, *, hide: bool) -> str:
|
||||
"""Show or hide the viewer count."""
|
||||
self.calls.append(RecordedCall("set_hide_viewer_count", {"hide": hide}))
|
||||
return ""
|
||||
|
||||
async def set_disable_search_indexing(self, *, disabled: bool) -> str:
|
||||
"""Enable or disable search engine indexing."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_disable_search_indexing", {"disabled": disabled})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_stream_keys(self, keys: list[StreamKey]) -> str:
|
||||
"""Set the stream keys."""
|
||||
self.calls.append(RecordedCall("set_stream_keys", {"keys": keys}))
|
||||
return ""
|
||||
|
||||
async def set_video_variants(self, variants: list[VideoVariant]) -> str:
|
||||
"""Set the video output variants (quality levels)."""
|
||||
self.calls.append(RecordedCall("set_video_variants", {"variants": variants}))
|
||||
return ""
|
||||
|
||||
async def set_video_codec(self, codec: str) -> str:
|
||||
"""Set the video codec."""
|
||||
self.calls.append(RecordedCall("set_video_codec", {"codec": codec}))
|
||||
return ""
|
||||
|
||||
async def set_video_serving_endpoint(self, endpoint: str) -> str:
|
||||
"""Set the video serving endpoint (e.g., for CDN)."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_video_serving_endpoint", {"endpoint": endpoint})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_stream_latency(self, level: int) -> str:
|
||||
"""Set the stream latency level."""
|
||||
self.calls.append(RecordedCall("set_stream_latency", {"level": level}))
|
||||
return ""
|
||||
|
||||
async def set_ffmpeg_path(self, path: str) -> str:
|
||||
"""Set the path to the ffmpeg binary."""
|
||||
self.calls.append(RecordedCall("set_ffmpeg_path", {"path": path}))
|
||||
return ""
|
||||
|
||||
async def set_socket_host_override(self, host: str) -> str:
|
||||
"""Set the WebSocket host override."""
|
||||
self.calls.append(RecordedCall("set_socket_host_override", {"host": host}))
|
||||
return ""
|
||||
|
||||
async def set_rtmp_port(self, port: int) -> str:
|
||||
"""Set the RTMP server port."""
|
||||
self.calls.append(RecordedCall("set_rtmp_port", {"port": port}))
|
||||
return ""
|
||||
|
||||
async def set_web_server_port(self, port: int) -> str:
|
||||
"""Set the web server port."""
|
||||
self.calls.append(RecordedCall("set_web_server_port", {"port": port}))
|
||||
return ""
|
||||
|
||||
async def set_web_server_ip(self, ip: str) -> str:
|
||||
"""Set the web server bind IP address."""
|
||||
self.calls.append(RecordedCall("set_web_server_ip", {"ip": ip}))
|
||||
return ""
|
||||
|
||||
async def set_s3_config(
|
||||
self,
|
||||
*,
|
||||
enabled: bool,
|
||||
endpoint: str,
|
||||
access_key: str,
|
||||
secret: str,
|
||||
bucket: str,
|
||||
region: str,
|
||||
) -> str:
|
||||
"""Set the S3 storage configuration."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"set_s3_config",
|
||||
{
|
||||
"enabled": enabled,
|
||||
"endpoint": endpoint,
|
||||
"access_key": access_key,
|
||||
"secret": secret,
|
||||
"bucket": bucket,
|
||||
"region": region,
|
||||
},
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_discord_notifications(
|
||||
self, *, enabled: bool, webhook: str, go_live_message: str
|
||||
) -> str:
|
||||
"""Set the Discord notification configuration."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"set_discord_notifications",
|
||||
{
|
||||
"enabled": enabled,
|
||||
"webhook": webhook,
|
||||
"go_live_message": go_live_message,
|
||||
},
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_browser_notifications(
|
||||
self, *, enabled: bool, go_live_message: str
|
||||
) -> str:
|
||||
"""Set the browser notification configuration."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"set_browser_notifications",
|
||||
{"enabled": enabled, "go_live_message": go_live_message},
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def upload_emoji(self, name: str, data_base64: str) -> str:
|
||||
"""Upload a custom emoji."""
|
||||
self.calls.append(
|
||||
RecordedCall("upload_emoji", {"name": name, "data_base64": data_base64})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def delete_emoji(self, name: str) -> str:
|
||||
"""Delete a custom emoji."""
|
||||
self.calls.append(RecordedCall("delete_emoji", {"name": name}))
|
||||
return ""
|
||||
|
||||
async def get_webhooks(self) -> list[dict[str, Any]]:
|
||||
"""Get all registered webhooks."""
|
||||
self.calls.append(RecordedCall("get_webhooks", {}))
|
||||
return []
|
||||
|
||||
async def create_webhook(self, url: str, events: list[str]) -> str:
|
||||
"""Create a new webhook."""
|
||||
self.calls.append(
|
||||
RecordedCall("create_webhook", {"url": url, "events": events})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def delete_webhook(self, webhook_id: int) -> str:
|
||||
"""Delete a webhook."""
|
||||
self.calls.append(RecordedCall("delete_webhook", {"webhook_id": webhook_id}))
|
||||
return ""
|
||||
|
||||
async def get_access_tokens(self) -> list[dict[str, Any]]:
|
||||
"""Get all access tokens."""
|
||||
self.calls.append(RecordedCall("get_access_tokens", {}))
|
||||
return []
|
||||
|
||||
async def create_access_token(self, name: str, scopes: list[str]) -> str:
|
||||
"""Create a new access token."""
|
||||
self.calls.append(
|
||||
RecordedCall("create_access_token", {"name": name, "scopes": scopes})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def delete_access_token(self, token: str) -> str:
|
||||
"""Delete an access token."""
|
||||
self.calls.append(RecordedCall("delete_access_token", {"token": token}))
|
||||
return ""
|
||||
|
||||
async def get_followers(self, offset: int = 0, limit: int = 25) -> dict[str, Any]:
|
||||
"""Get a paginated list of followers."""
|
||||
self.calls.append(
|
||||
RecordedCall("get_followers", {"offset": offset, "limit": limit})
|
||||
)
|
||||
return {}
|
||||
|
||||
async def get_pending_follow_requests(self) -> list[dict[str, Any]]:
|
||||
"""Get pending follow requests."""
|
||||
self.calls.append(RecordedCall("get_pending_follow_requests", {}))
|
||||
return []
|
||||
|
||||
async def get_blocked_followers(self) -> list[dict[str, Any]]:
|
||||
"""Get blocked and rejected followers."""
|
||||
self.calls.append(RecordedCall("get_blocked_followers", {}))
|
||||
return []
|
||||
|
||||
async def approve_follower(self, actor_iri: str, *, approved: bool) -> str:
|
||||
"""Approve or reject a follow request."""
|
||||
self.calls.append(
|
||||
RecordedCall(
|
||||
"approve_follower", {"actor_iri": actor_iri, "approved": approved}
|
||||
)
|
||||
)
|
||||
return ""
|
||||
|
||||
async def send_federated_message(self, message: str) -> str:
|
||||
"""Send a message to all followers via federation."""
|
||||
self.calls.append(RecordedCall("send_federated_message", {"message": message}))
|
||||
return ""
|
||||
|
||||
async def set_federation_enabled(self, *, enabled: bool) -> str:
|
||||
"""Enable or disable federation (ActivityPub)."""
|
||||
self.calls.append(RecordedCall("set_federation_enabled", {"enabled": enabled}))
|
||||
return ""
|
||||
|
||||
async def set_federation_username(self, name: str) -> str:
|
||||
"""Set the federation (ActivityPub) username."""
|
||||
self.calls.append(RecordedCall("set_federation_username", {"name": name}))
|
||||
return ""
|
||||
|
||||
async def set_federation_go_live_message(self, message: str) -> str:
|
||||
"""Set the message sent to followers when going live."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_federation_go_live_message", {"message": message})
|
||||
)
|
||||
return ""
|
||||
|
||||
async def set_federation_blocked_domains(self, domains: list[str]) -> str:
|
||||
"""Set the list of blocked federation domains."""
|
||||
self.calls.append(
|
||||
RecordedCall("set_federation_blocked_domains", {"domains": domains})
|
||||
)
|
||||
return ""
|
||||
Reference in New Issue
Block a user