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

- 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:
2026-04-22 21:49:16 -04:00
parent 32e21a6f9d
commit a671541b13
25 changed files with 8195 additions and 3093 deletions
+49
View File
@@ -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",
]
+330
View File
@@ -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,
)
+459
View File
@@ -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)
+608
View File
@@ -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 ""