Updated Owncast clients to v0.2.5 and added spec-coverage and integration tests to enforce parity with the OpenAPI spec.
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 2m55s
CI / Tests (Python 3.13) (push) Successful in 2m46s
CI / Tests (Python 3.14) (push) Successful in 2m42s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 9s

This commit is contained in:
2026-04-24 16:57:11 -04:00
parent 44069793f8
commit f20faa317e
17 changed files with 7826 additions and 808 deletions
+29
View File
@@ -20,4 +20,33 @@ loaded via ``pytest_plugins`` below.
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from collections.abc import Iterable
pytest_plugins = ["owlbot.testing.plugin", "pytester"]
def pytest_addoption(parser: pytest.Parser) -> None:
"""Register ``--run-integration`` for the integration-test opt-in flag."""
parser.addoption(
"--run-integration",
action="store_true",
default=False,
help="Run integration tests that require downloading Owncast",
)
def pytest_collection_modifyitems(
config: pytest.Config, items: Iterable[pytest.Item]
) -> None:
"""Skip tests marked ``integration`` unless ``--run-integration`` is passed."""
if config.getoption("--run-integration"):
return
skip = pytest.mark.skip(reason="requires --run-integration")
for item in items:
if "integration" in item.keywords:
item.add_marker(skip)
+4570
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
"""End-to-end integration tests for the Owncast API clients."""
+398
View File
@@ -0,0 +1,398 @@
# 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.
"""Integration test fixtures and harness for the Owncast API clients."""
from __future__ import annotations
import asyncio
import hashlib
import platform
import secrets
import shutil
import socket
import subprocess
import tempfile
import urllib.request
import zipfile
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Final, NamedTuple
import pytest
import pytest_asyncio
import uvloop
from owlbot import OWNCAST_TARGET_VERSION
from owlbot.api.http_client import HttpClient
from owlbot.api.owncast_admin_client import OwncastAdminClient
from owlbot.api.owncast_client import OwncastClient
from owlbot.owncast_http import OwncastError
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator
_RELEASE_URL_TEMPLATE: Final = (
"https://github.com/owncast/owncast/releases/download/"
"v{version}/owncast-{version}-{archive}.zip"
)
_REPO_ROOT: Final = Path(__file__).resolve().parents[2]
_CACHE_DIR: Final = _REPO_ROOT / ".owncast-test"
_DEFAULT_ADMIN_USERNAME: Final = "admin"
_DEFAULT_ADMIN_PASSWORD: Final = "abc123" # noqa: S105 # documented Owncast default
_INTEGRATION_SCOPES: Final[list[str]] = [
"CAN_SEND_SYSTEM_MESSAGES",
"CAN_SEND_MESSAGES",
"HAS_ADMIN_ACCESS",
]
class _PlatformRelease(NamedTuple):
"""Archive name and expected SHA256 for a single release platform."""
archive: str
sha256: str
_PLATFORM_RELEASES: Final[dict[tuple[str, str], _PlatformRelease]] = {
("Linux", "x86_64"): _PlatformRelease(
"linux-64bit",
"3a197c123d80e5902e70691483180c3305b7784e7c6efcd26730bfd0d5bbd465",
),
("Linux", "i686"): _PlatformRelease(
"linux-32bit",
"daf6a2df265260d950fa544af98a7f0ee5a89c86dd047d1c810a13cae576a71e",
),
("Linux", "aarch64"): _PlatformRelease(
"linux-arm64",
"4b2b9db1d2f899b854d1c670626f839ae7da6808daef4d73f0208432e16e1c65",
),
("Linux", "armv7l"): _PlatformRelease(
"linux-arm7",
"b6d5b1f06b3f5b78ea3c2af1bf582c1cae521235632af0c9383f4f7af0527738",
),
("Darwin", "x86_64"): _PlatformRelease(
"macOS-64bit",
"bfef49e719ad9316554855df44a9ac21aec5f2a74de79eef15b6947cb383c2bb",
),
("Darwin", "arm64"): _PlatformRelease(
"macOS-arm64",
"7c1479e3a2e2185587b2197511860b0446f9b96591c76a20d1e2319a346f4833",
),
}
@dataclass(slots=True)
class OwncastServer:
"""Running Owncast subprocess bound to chosen HTTP and RTMP ports."""
binary: Path
tmp_dir: Path
http_port: int
rtmp_port: int
process: subprocess.Popen[bytes] | None = None
@property
def base_url(self) -> str:
"""Base URL for the running server (no trailing slash)."""
return f"http://127.0.0.1:{self.http_port}"
def start(self, startup_timeout: float = 30.0) -> None:
"""Launch Owncast and poll until ``/api/status`` responds."""
self.process = subprocess.Popen( # noqa: S603 # binary path is a cached, hash-verified Owncast release
[
str(self.binary),
"-webserverport",
str(self.http_port),
"-rtmpport",
str(self.rtmp_port),
],
cwd=self.tmp_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
uvloop.run(self._poll_until_ready(startup_timeout))
except BaseException:
self.stop()
raise
async def _poll_until_ready(self, startup_timeout: float) -> None:
last_exc: OwncastError | None = None
http = HttpClient()
await http._start()
try:
client = OwncastClient(
base_url=self.base_url, access_token="", http_client=http
)
async with asyncio.timeout(startup_timeout):
while True:
if self.process is not None and self.process.poll() is not None:
msg = (
f"Owncast exited before becoming ready "
f"(code={self.process.returncode})."
)
raise RuntimeError(msg)
try:
await client.get_status()
except OwncastError as exc:
last_exc = exc
else:
return
await asyncio.sleep(0.25)
except TimeoutError as exc:
msg = (
f"Owncast did not become ready within {startup_timeout}s. "
f"Last error: {last_exc}."
)
raise RuntimeError(msg) from exc
finally:
await http._close()
def stop(self, shutdown_timeout: float = 10.0) -> None:
"""Terminate the subprocess, escalating to SIGKILL if needed."""
if self.process is None:
return
if self.process.poll() is None:
self.process.terminate()
try:
self.process.wait(timeout=shutdown_timeout)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait()
self.process = None
@dataclass(frozen=True, slots=True)
class AdminCredentials:
"""Admin credentials rotated at session start."""
username: str
password: str
def _cached_binary_path(release: _PlatformRelease) -> Path:
"""Return the path where an extracted binary for ``release`` lives."""
return _CACHE_DIR / f"v{OWNCAST_TARGET_VERSION}" / release.archive / "owncast"
def _cached_archive_path(release: _PlatformRelease) -> Path:
"""Return the path where the downloaded archive for ``release`` is cached."""
return _CACHE_DIR / f"v{OWNCAST_TARGET_VERSION}" / release.archive / "archive.zip"
@pytest.fixture(scope="session")
def owncast_binary() -> Path:
"""Path to a cached, verified Owncast binary for the current platform.
On cache hit the pinned SHA256 is re-checked against the cached
archive; on miss or mismatch the archive is re-downloaded, verified,
and extracted. Raises ``RuntimeError`` on network failure, hash
mismatch, or unsupported host platform.
"""
key = (platform.system(), platform.machine())
try:
release = _PLATFORM_RELEASES[key]
except KeyError as exc:
supported = ", ".join(f"{s}/{m}" for s, m in _PLATFORM_RELEASES)
msg = (
f"Unsupported platform {key[0]}/{key[1]} for Owncast integration "
f"tests. Supported: {supported}."
)
raise RuntimeError(msg) from exc
archive_path = _cached_archive_path(release)
binary_path = _cached_binary_path(release)
# Re-hash on hit so bumping the pinned SHA256 forces a re-download
# to pick up the new upstream archive.
if (
archive_path.is_file()
and binary_path.is_file()
and hashlib.sha256(archive_path.read_bytes()).hexdigest() == release.sha256
):
return binary_path
# Stream to a tempfile and hash while downloading, so a tampered or
# truncated download can't overwrite a valid cached copy.
url = _RELEASE_URL_TEMPLATE.format(
version=OWNCAST_TARGET_VERSION, archive=release.archive
)
hasher = hashlib.sha256()
with tempfile.NamedTemporaryFile(
prefix=f"owncast-v{OWNCAST_TARGET_VERSION}-{release.archive}-",
suffix=".zip",
delete=False,
) as tmp_file:
tmp_path = Path(tmp_file.name)
with urllib.request.urlopen(url) as response: # noqa: S310 # hash-verified below
while chunk := response.read(65536):
tmp_file.write(chunk)
hasher.update(chunk)
try:
actual_hash = hasher.hexdigest()
if actual_hash != release.sha256:
msg = (
f"SHA256 mismatch for downloaded Owncast archive. "
f"expected={release.sha256} actual={actual_hash}"
)
raise RuntimeError(msg)
# Hash matched: wipe the extract dir (in case of partial prior
# downloads), move the verified archive in, extract alongside.
extract_root = _CACHE_DIR / f"v{OWNCAST_TARGET_VERSION}" / release.archive
if extract_root.exists():
shutil.rmtree(extract_root)
extract_root.mkdir(parents=True)
shutil.move(tmp_path, archive_path)
with zipfile.ZipFile(archive_path) as archive:
archive.extractall(extract_root)
if not binary_path.exists():
msg = f"Extracted archive did not contain owncast binary at {binary_path}."
raise RuntimeError(msg)
binary_path.chmod(0o755)
return binary_path
finally:
tmp_path.unlink(missing_ok=True)
@pytest.fixture
def owncast_server(
owncast_binary: Path, tmp_path_factory: pytest.TempPathFactory
) -> Iterator[str]:
"""Per-test Owncast server on two kernel-assigned ports; yields base URL.
Scoped per test so each test gets a fresh subprocess and a fresh
working directory, eliminating cross-test config-state pollution.
"""
tmp_dir = tmp_path_factory.mktemp("owncast")
# Bind both sockets before closing either so the kernel doesn't
# hand out the same port twice.
with (
socket.socket(socket.AF_INET, socket.SOCK_STREAM) as http_sock,
socket.socket(socket.AF_INET, socket.SOCK_STREAM) as rtmp_sock,
):
for sock in (http_sock, rtmp_sock):
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 0))
http_port = http_sock.getsockname()[1]
rtmp_port = rtmp_sock.getsockname()[1]
server = OwncastServer(
binary=owncast_binary,
tmp_dir=tmp_dir,
http_port=http_port,
rtmp_port=rtmp_port,
)
server.start()
try:
yield server.base_url
finally:
server.stop()
@asynccontextmanager
async def _admin_session(
base_url: str, username: str, password: str
) -> AsyncIterator[OwncastAdminClient]:
"""Yield a short-lived ``OwncastAdminClient`` with a managed HTTP session."""
http = HttpClient()
await http._start()
try:
yield OwncastAdminClient(base_url, username, password, http)
finally:
await http._close()
@pytest.fixture
def admin_credentials(owncast_server: str) -> AdminCredentials:
"""Rotate the default admin password via the real admin client."""
new_password = secrets.token_urlsafe(32)
async def _rotate() -> None:
async with _admin_session(
owncast_server, _DEFAULT_ADMIN_USERNAME, _DEFAULT_ADMIN_PASSWORD
) as client:
await client.set_admin_password(new_password)
uvloop.run(_rotate())
return AdminCredentials(username=_DEFAULT_ADMIN_USERNAME, password=new_password)
@pytest.fixture
def integration_token(owncast_server: str, admin_credentials: AdminCredentials) -> str:
"""Mint an integration access token via the real admin client.
``create_access_token`` on the client returns only the envelope message,
so the token value is retrieved with a follow-up ``get_access_tokens``
listing (matched by ``displayName``).
"""
token_name = f"owlbot-integration-tests-{secrets.token_hex(4)}"
async def _mint() -> str:
async with _admin_session(
owncast_server, admin_credentials.username, admin_credentials.password
) as client:
await client.create_access_token(token_name, _INTEGRATION_SCOPES)
tokens = await client.get_access_tokens()
for entry in tokens:
if isinstance(entry, dict) and entry.get("displayName") == token_name:
return str(entry["accessToken"])
msg = f"Newly-created token {token_name!r} not found in listing."
raise RuntimeError(msg)
return uvloop.run(_mint())
@pytest_asyncio.fixture
async def owncast_http() -> AsyncIterator[HttpClient]:
"""Fresh ``HttpClient`` per test, started and torn down cleanly."""
client = HttpClient()
await client._start()
try:
yield client
finally:
await client._close()
@pytest.fixture
def owncast_admin_client(
owncast_server: str,
admin_credentials: AdminCredentials,
owncast_http: HttpClient,
) -> OwncastAdminClient:
"""Real ``OwncastAdminClient`` bound to the live server and current creds."""
return OwncastAdminClient(
owncast_server,
admin_credentials.username,
admin_credentials.password,
owncast_http,
)
@pytest.fixture
def owncast_client(
owncast_server: str,
integration_token: str,
owncast_http: HttpClient,
) -> OwncastClient:
"""Real ``OwncastClient`` bound to the live server and bootstrap token."""
return OwncastClient(
owncast_server,
integration_token,
owncast_http,
)
File diff suppressed because it is too large Load Diff
+181
View File
@@ -0,0 +1,181 @@
# 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.
"""Integration tests for ``OwncastClient`` against a live Owncast server."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from owlbot.owncast_http import OwncastError
if TYPE_CHECKING:
from owlbot.api.owncast_client import OwncastClient
pytestmark = pytest.mark.integration
def test_base_url_matches_server(
owncast_client: OwncastClient, owncast_server: str
) -> None:
"""The client's base_url property reflects the server it was built for."""
assert owncast_client.base_url == owncast_server
class TestRoundTrip:
"""End-to-end tests whose effect is verified via readback or return value.
Either the call is a getter whose return value is the observation, or the
call is a setter/mutation whose effect is read back within the same test.
"""
async def test_send_message_returns_success_envelope(
self, owncast_client: OwncastClient
) -> None:
"""send_message posts a chat message and returns the success envelope."""
result = await owncast_client.send_message("integration test message")
assert isinstance(result, str)
async def test_send_message_with_unsanitized_true(
self, owncast_client: OwncastClient
) -> None:
"""send_message accepts raw HTML when unsanitized=True."""
result = await owncast_client.send_message("<b>bold</b>", unsanitized=True)
assert isinstance(result, str)
async def test_send_system_message_returns_success_envelope(
self, owncast_client: OwncastClient
) -> None:
"""send_system_message posts a system message."""
result = await owncast_client.send_system_message("system notice")
assert isinstance(result, str)
async def test_send_action_returns_success_envelope(
self, owncast_client: OwncastClient
) -> None:
"""send_action posts an action message."""
result = await owncast_client.send_action("does a test thing")
assert isinstance(result, str)
async def test_send_user_message_always_returns_400(
self, owncast_client: OwncastClient
) -> None:
"""send_user_message is deprecated in Owncast v0.2.5 and returns 400.
Validates the deprecation contract documented on
OwncastClient.send_user_message.
"""
with pytest.raises(OwncastError) as exc_info:
await owncast_client.send_user_message()
assert exc_info.value.status == 400
async def test_get_chat_history_is_empty_on_fresh_server(
self, owncast_client: OwncastClient
) -> None:
"""get_chat_history returns [] on a fresh server with no sent messages."""
assert await owncast_client.get_chat_history() == []
async def test_get_connected_clients_is_empty_on_fresh_server(
self, owncast_client: OwncastClient
) -> None:
"""get_connected_clients returns [] on a fresh server with no viewers."""
assert await owncast_client.get_connected_clients() == []
async def test_set_message_visibility_after_sending(
self, owncast_client: OwncastClient
) -> None:
"""set_message_visibility accepts message IDs read from history.
Sends a message, reads chat history to obtain its id, hides it, then
shows it again. Validates the full write-read-mutate round trip.
"""
await owncast_client.send_message("visibility-test message")
history = await owncast_client.get_chat_history()
assert history, "chat history should contain the just-sent message"
message_id = str(history[-1]["id"])
hide_result = await owncast_client.set_message_visibility(
[message_id], visible=False
)
assert isinstance(hide_result, str)
show_result = await owncast_client.set_message_visibility(
[message_id], visible=True
)
assert isinstance(show_result, str)
async def test_get_user_details_for_existing_user(
self, owncast_client: OwncastClient
) -> None:
"""get_user_details returns a dict for a user extracted from chat history.
The integration bot sends a message, then reads chat history to get
its own user id, and looks it up. This is the only way to test the
endpoint end-to-end since Owncast returns 404 for unknown user ids
(so we can't distinguish "route missing" from "user missing" without
a real id).
"""
await owncast_client.send_message("user-details-test message")
history = await owncast_client.get_chat_history()
assert history
user_id = str(history[-1]["user"]["id"])
details = await owncast_client.get_user_details(user_id)
assert isinstance(details, dict)
async def test_get_status_returns_dict(self, owncast_client: OwncastClient) -> None:
"""get_status reports offline on a fresh server.
The public ``/api/status`` endpoint does not expose ``viewerCount``
(that is only surfaced to admin callers). ``serverTime`` is always
populated but its value is a moving target, so only its presence
is asserted.
"""
status = await owncast_client.get_status()
assert isinstance(status, dict)
assert status["online"] is False
assert "serverTime" in status
class TestSmoke:
"""Tests that only verify the wrapper reaches the right endpoint.
The backing effect is either unobservable from the integration client
(``get_status`` does not surface the configured stream title) or concerns
clients that are not connected in a fresh install. Assertions validate
that the request is routed correctly, not that any effect was applied.
"""
async def test_send_system_message_to_client_accepts_unknown_id(
self, owncast_client: OwncastClient
) -> None:
"""send_system_message_to_client returns success for unknown client IDs.
A fresh Owncast has no connected clients. Owncast silently accepts
system messages to unknown client IDs (the message is discarded).
The assertion only validates the request path reaches the server and
the envelope parses, not that anyone received the message.
"""
result = await owncast_client.send_system_message_to_client(1, "hello")
assert isinstance(result, str)
async def test_set_stream_title_succeeds(
self, owncast_client: OwncastClient
) -> None:
"""set_stream_title returns the success envelope.
The /api/status endpoint does not surface streamTitle, so we only
validate the envelope. Admin-side test covers reading the value back.
"""
result = await owncast_client.set_stream_title("Integration Test Stream")
assert isinstance(result, str)
+356
View File
@@ -0,0 +1,356 @@
# 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.
"""Spec-coverage tests for the Owncast API clients.
Enforces a three-way invariant across:
- the pinned Owncast OpenAPI spec (``openapi-v<version>.yaml``),
- the hand-curated mapping dicts (``INTEGRATION_OPERATIONS`` and
``ADMIN_OPERATIONS``),
- the public async methods on the client classes (``OwncastClient`` and
``OwncastAdminClient``).
Four tests, each run for both the integration and admin surfaces, form a
closed loop around the mapping::
spec --(1)--> mapping --(2)--> method
<--(3)-- <--(4)--
(1) ``test_*_spec_op_is_mapped``
Every spec operationId has a mapping entry.
Fails when Owncast added an endpoint.
Fix: wrap it as a method on the client, add an entry to the mapping.
(2) ``test_*_mapping_targets_real_method``
Every mapping value resolves to an async method on the client.
Fails when the client is missing a method the mapping claims exists.
Fix: add or rename the method on the client class to match the mapping.
(3) ``test_*_mapping_is_not_orphaned``
Every mapping key still exists in the spec.
Fails when upstream removed or renamed the operation.
Fix: drop the entry (removal) or update its key (rename).
(4) ``test_*_method_is_mapped``
Every client method is referenced by the mapping.
Fails when a method was added without being registered.
Fix: add it to the mapping, or delete the method.
A fifth test, ``test_method_declaration_order_matches_spec``, pins the
declaration order of mapped methods on the real clients and their
recording stubs to match the spec document's operationId order.
"""
from __future__ import annotations
import inspect
from pathlib import Path
from typing import Any, Final
import pytest
from ruamel.yaml import YAML
from owlbot import OWNCAST_TARGET_VERSION
from owlbot.api.owncast_admin_client import OwncastAdminClient
from owlbot.api.owncast_client import OwncastClient
from owlbot.testing.stubs import RecordingOwncastAdminClient, RecordingOwncastClient
SPEC_PATH: Final = (
Path(__file__).parent / "fixtures" / f"openapi-v{OWNCAST_TARGET_VERSION}.yaml"
)
INTEGRATION_OPERATIONS: Final[dict[str, str]] = {
"ExternalGetChatMessages": "get_chat_history",
"ExternalGetConnectedChatClients": "get_connected_clients",
"ExternalGetStatus": "get_status",
"ExternalGetUserDetails": "get_user_details",
"ExternalSetStreamTitle": "set_stream_title",
"ExternalUpdateMessageVisibility": "set_message_visibility",
"SendChatAction": "send_action",
"SendIntegrationChatMessage": "send_message",
"SendSystemMessage": "send_system_message",
"SendSystemMessageToConnectedClient": "send_system_message_to_client",
"SendUserMessage": "send_user_message",
}
ADMIN_OPERATIONS: Final[dict[str, str]] = {
"ApproveFollower": "approve_follower",
"BanIPAddress": "ban_ip_address",
"CreateExternalAPIUser": "create_access_token",
"CreateWebhook": "create_webhook",
"DeleteCustomEmoji": "delete_emoji",
"DeleteExternalAPIUser": "delete_access_token",
"DeletePrometheusAPI": "delete_prometheus",
"DeleteWebhook": "delete_webhook",
"DisconnectInboundConnection": "disconnect_stream",
"GetActiveViewers": "get_active_viewers",
"GetBlockedAndRejectedFollowers": "get_blocked_followers",
"GetChatMessagesAdmin": "get_chat_messages",
"GetConnectedChatClients": "get_connected_chat_clients",
"GetDisabledUsers": "get_disabled_users",
"GetExternalAPIUsers": "get_access_tokens",
"GetFederatedActions": "get_federated_actions",
"GetFollowersAdmin": "get_followers",
"GetHardwareStats": "get_hardware_stats",
"GetIPAddressBans": "get_ip_address_bans",
"GetLogs": "get_logs",
"GetModerators": "get_moderators",
"GetPendingFollowRequests": "get_pending_follow_requests",
"GetPrometheusAPI": "get_prometheus_metrics",
"GetServerConfig": "get_server_config",
"GetVideoPlaybackMetrics": "get_playback_metrics",
"GetViewersOverTime": "get_viewers_over_time",
"GetWarnings": "get_warnings",
"GetWebhooks": "get_webhooks",
"PostPrometheusAPI": "post_prometheus",
"PutPrometheusAPI": "put_prometheus",
"ResetFavicon": "reset_favicon",
"ResetYPRegistration": "reset_yp_registration",
"SendFederatedMessage": "send_federated_message",
"SetAdminPassword": "set_admin_password",
"SetBrowserNotificationConfiguration": "set_browser_notifications",
"SetChatDisabled": "set_chat_disabled",
"SetChatJoinMessagesEnabled": "set_chat_join_messages_enabled",
"SetChatRequireAuthentication": "set_chat_require_authentication",
"SetChatSlurFilterEnabled": "set_chat_slur_filter",
"SetChatSpamProtectionEnabled": "set_chat_spam_protection",
"SetCustomColorVariableValues": "set_color_variables",
"SetCustomJavascript": "set_custom_javascript",
"SetCustomOfflineMessage": "set_offline_message",
"SetCustomStyles": "set_custom_styles",
"SetDirectoryEnabled": "set_directory_enabled",
"SetDisableSearchIndexing": "set_disable_search_indexing",
"SetDiscordNotificationConfiguration": "set_discord_notifications",
"SetEnableEstablishedChatUserMode": "set_chat_established_mode",
"SetExternalActions": "set_external_actions",
"SetExtraPageContent": "set_page_content",
"SetFavicon": "set_favicon",
"SetFederationActivityPrivate": "set_federation_activity_private",
"SetFederationBlockDomains": "set_federation_blocked_domains",
"SetFederationEnabled": "set_federation_enabled",
"SetFederationGoLiveMessage": "set_federation_go_live_message",
"SetFederationShowEngagement": "set_federation_show_engagement",
"SetFederationUsername": "set_federation_username",
"SetFfmpegPath": "set_ffmpeg_path",
"SetForbiddenUsernameList": "set_forbidden_usernames",
"SetHideViewerCount": "set_hide_viewer_count",
"SetLogo": "set_logo",
"SetNSFW": "set_nsfw",
"SetRTMPServerPort": "set_rtmp_port",
"SetS3Configuration": "set_s3_config",
"SetServerName": "set_server_name",
"SetServerSummary": "set_server_summary",
"SetServerURL": "set_server_url",
"SetServerWelcomeMessage": "set_welcome_message",
"SetSocialHandles": "set_social_handles",
"SetSocketHostOverride": "set_socket_host_override",
"SetStreamKeys": "set_stream_keys",
"SetStreamLatencyLevel": "set_stream_latency",
"SetStreamOutputVariants": "set_video_variants",
"SetStreamTitle": "set_stream_title",
"SetSuggestedUsernameList": "set_suggested_usernames",
"SetTags": "set_tags",
"SetVideoCodec": "set_video_codec",
"SetVideoServingEndpoint": "set_video_serving_endpoint",
"SetWebServerIP": "set_web_server_ip",
"SetWebServerPort": "set_web_server_port",
"StatusAdmin": "get_status",
"UnbanIPAddress": "unban_ip_address",
"UpdateMessageVisibilityAdmin": "set_message_visibility",
"UpdateUserEnabledAdmin": "set_user_enabled",
"UpdateUserModerator": "set_user_moderator",
"UploadCustomEmoji": "upload_emoji",
}
def load_spec() -> dict[str, Any]:
"""Parse the pinned Owncast OpenAPI spec as a mapping."""
yaml = YAML(typ="safe")
with SPEC_PATH.open() as f:
loaded = yaml.load(f)
if not isinstance(loaded, dict):
raise TypeError(f"Expected mapping at spec root, got {type(loaded).__name__}.")
return loaded
def spec_operation_ids(spec: dict[str, Any], path_prefix: str) -> list[str]:
"""Return operationIds for non-OPTIONS, non-internal ops under ``path_prefix``.
Order follows the spec document: paths in declaration order, methods
within each path in declaration order.
:raises RuntimeError: If any covered operation is missing a string
``operationId``.
"""
oids: list[str] = []
missing: list[str] = []
for path, methods in spec["paths"].items():
if not path.startswith(path_prefix):
continue
for method, op in methods.items():
if method == "options" or not isinstance(op, dict):
continue
if op.get("x-internal"):
continue
oid = op.get("operationId")
if isinstance(oid, str):
oids.append(oid)
else:
missing.append(f"{method.upper()} {path}")
if missing:
raise RuntimeError(
f"Spec operations under {path_prefix!r} missing operationId: "
f"{', '.join(missing)}"
)
return oids
def public_async_methods(cls: type) -> list[str]:
"""Return public async method names in class declaration order."""
return [
name
for name, obj in cls.__dict__.items()
if not name.startswith("_") and inspect.iscoroutinefunction(obj)
]
SPEC = load_spec()
INTEGRATION_SPEC_OPS = spec_operation_ids(SPEC, "/integrations/")
ADMIN_SPEC_OPS = spec_operation_ids(SPEC, "/admin/")
INTEGRATION_METHODS = public_async_methods(OwncastClient)
ADMIN_METHODS = public_async_methods(OwncastAdminClient)
@pytest.mark.parametrize("operation_id", sorted(INTEGRATION_SPEC_OPS))
def test_integration_spec_op_is_mapped(operation_id: str) -> None:
"""Every /integrations/ operationId has a mapping in INTEGRATION_OPERATIONS."""
assert operation_id in INTEGRATION_OPERATIONS, (
f"Spec operation {operation_id!r} under /integrations/ has no mapping "
f"in INTEGRATION_OPERATIONS. Wrap it on OwncastClient and add the mapping."
)
@pytest.mark.parametrize("operation_id", sorted(INTEGRATION_OPERATIONS))
def test_integration_mapping_targets_real_method(operation_id: str) -> None:
"""Every INTEGRATION_OPERATIONS entry targets an OwncastClient method."""
method_name = INTEGRATION_OPERATIONS[operation_id]
assert method_name in INTEGRATION_METHODS, (
f"INTEGRATION_OPERATIONS[{operation_id!r}] points to {method_name!r}, "
f"but OwncastClient has no public async method with that name."
)
@pytest.mark.parametrize("operation_id", sorted(INTEGRATION_OPERATIONS))
def test_integration_mapping_is_not_orphaned(operation_id: str) -> None:
"""Every INTEGRATION_OPERATIONS key matches a /integrations/ operationId."""
assert operation_id in INTEGRATION_SPEC_OPS, (
f"INTEGRATION_OPERATIONS has {operation_id!r}, but no such operation "
f"exists under /integrations/ in the pinned spec. "
f"Either the spec removed it or the mapping key is misspelled."
)
@pytest.mark.parametrize("method_name", sorted(INTEGRATION_METHODS))
def test_integration_method_is_mapped(method_name: str) -> None:
"""Every async OwncastClient method is referenced by INTEGRATION_OPERATIONS."""
mapped = set(INTEGRATION_OPERATIONS.values())
assert method_name in mapped, (
f"OwncastClient.{method_name} is not referenced by INTEGRATION_OPERATIONS. "
f"Either map it to a spec operationId or remove the method."
)
@pytest.mark.parametrize("operation_id", sorted(ADMIN_SPEC_OPS))
def test_admin_spec_op_is_mapped(operation_id: str) -> None:
"""Every /admin/ operationId has a mapping in ADMIN_OPERATIONS."""
assert operation_id in ADMIN_OPERATIONS, (
f"Spec operation {operation_id!r} under /admin/ has no mapping "
f"in ADMIN_OPERATIONS. Wrap it on OwncastAdminClient and add the mapping."
)
@pytest.mark.parametrize("operation_id", sorted(ADMIN_OPERATIONS))
def test_admin_mapping_targets_real_method(operation_id: str) -> None:
"""Every ADMIN_OPERATIONS entry targets an OwncastAdminClient method."""
method_name = ADMIN_OPERATIONS[operation_id]
assert method_name in ADMIN_METHODS, (
f"ADMIN_OPERATIONS[{operation_id!r}] points to {method_name!r}, "
f"but OwncastAdminClient has no public async method with that name."
)
@pytest.mark.parametrize("operation_id", sorted(ADMIN_OPERATIONS))
def test_admin_mapping_is_not_orphaned(operation_id: str) -> None:
"""Every ADMIN_OPERATIONS key matches an /admin/ operationId."""
assert operation_id in ADMIN_SPEC_OPS, (
f"ADMIN_OPERATIONS has {operation_id!r}, but no such operation "
f"exists under /admin/ in the pinned spec. "
f"Either the spec removed it or the mapping key is misspelled."
)
@pytest.mark.parametrize("method_name", sorted(ADMIN_METHODS))
def test_admin_method_is_mapped(method_name: str) -> None:
"""Every async OwncastAdminClient method is referenced by ADMIN_OPERATIONS."""
mapped = set(ADMIN_OPERATIONS.values())
assert method_name in mapped, (
f"OwncastAdminClient.{method_name} is not referenced by ADMIN_OPERATIONS. "
f"Either map it to a spec operationId or remove the method."
)
@pytest.mark.parametrize(
("cls", "mapping", "path_prefix"),
[
pytest.param(
OwncastClient, INTEGRATION_OPERATIONS, "/integrations/", id="OwncastClient"
),
pytest.param(
RecordingOwncastClient,
INTEGRATION_OPERATIONS,
"/integrations/",
id="RecordingOwncastClient",
),
pytest.param(
OwncastAdminClient, ADMIN_OPERATIONS, "/admin/", id="OwncastAdminClient"
),
pytest.param(
RecordingOwncastAdminClient,
ADMIN_OPERATIONS,
"/admin/",
id="RecordingOwncastAdminClient",
),
],
)
def test_method_declaration_order_matches_spec(
cls: type, mapping: dict[str, str], path_prefix: str
) -> None:
"""Public async methods are declared in the same order as the spec's operationIds.
Only methods present in ``mapping`` participate; unmapped ops are ignored
here because ``test_*_spec_op_is_mapped`` already covers coverage. This
test pins the *order* of the mapped methods against the spec document.
"""
spec_order = spec_operation_ids(SPEC, path_prefix)
expected = [mapping[oid] for oid in spec_order if oid in mapping]
expected_set = set(expected)
actual = [m for m in public_async_methods(cls) if m in expected_set]
assert actual == expected, (
f"{cls.__name__} method declaration order does not match spec order.\n"
f"Expected (spec order):\n "
+ "\n ".join(expected)
+ "\nActual (declaration order):\n "
+ "\n ".join(actual)
)
+6 -1
View File
@@ -17,7 +17,8 @@
from __future__ import annotations
import inspect
from typing import Any, get_origin, get_type_hints
import types
from typing import Any, Union, get_args, get_origin, get_type_hints
import pytest
@@ -47,6 +48,10 @@ def _synthesize_value(param_name: str, annotation: Any) -> Any:
originally-passed kwargs.
"""
origin = get_origin(annotation)
if origin in (types.UnionType, Union):
non_none = [arg for arg in get_args(annotation) if arg is not type(None)]
if len(non_none) == 1:
return _synthesize_value(param_name, non_none[0])
if origin is list:
return []
if origin is dict: