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
+2 -2
View File
@@ -76,13 +76,13 @@ jobs:
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with: with:
path: .owncast-test path: .owncast-test
key: owncast-${{ hashFiles('tests/conftest.py') }}-${{ runner.os }}-${{ runner.arch }} key: owncast-${{ hashFiles('tests/integration/conftest.py') }}-${{ runner.os }}-${{ runner.arch }}
- name: Install dependencies - name: Install dependencies
run: uv sync run: uv sync
- name: Run unit tests with Pytest - name: Run unit tests with Pytest
run: uv run pytest -v --cov --cov-report= run: uv run pytest -v --cov --cov-report= --run-integration
- name: Report code coverage - name: Report code coverage
run: uv run coverage report run: uv run coverage report
+1
View File
@@ -31,6 +31,7 @@ modules/
# Test / coverage artifacts # Test / coverage artifacts
.coverage .coverage
.pytest_cache/ .pytest_cache/
.owncast-test/
# Local configuration (keep config.yaml.example in repo) # Local configuration (keep config.yaml.example in repo)
config.yaml config.yaml
+1 -1
Submodule docs updated: eb0d4e37b5...c8be588c4d
+1 -1
View File
@@ -24,7 +24,7 @@ try:
except ImportError: except ImportError:
__version__ = "0.0.0+unknown" __version__ = "0.0.0+unknown"
OWNCAST_TARGET_VERSION = "0.2.4" OWNCAST_TARGET_VERSION = "0.2.5"
from .bot import Owlbot, StartupError from .bot import Owlbot, StartupError
File diff suppressed because it is too large Load Diff
+79 -55
View File
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING, Any
from markupsafe import escape as _escape_html from markupsafe import escape as _escape_html
from owlbot.owncast_http import get_json, post_with_envelope from owlbot.owncast_http import get_json, request_with_envelope
if TYPE_CHECKING: if TYPE_CHECKING:
import aiohttp import aiohttp
@@ -59,35 +59,6 @@ class OwncastClient:
"""The Owncast server base URL (e.g., "https://stream.logal.dev").""" """The Owncast server base URL (e.g., "https://stream.logal.dev")."""
return self._base_url return self._base_url
async def get_status(self) -> dict[str, Any]:
"""Get the public server status.
This is a public endpoint that does not require authentication.
Returns server info including version, online status, and viewer count.
:return: Status dict with ``versionNumber``, ``online``, ``viewerCount``, etc.
"""
self._logger.debug("Fetching server status.")
return dict(await self._get("/api/status"))
async def send_message(self, body: str, *, unsanitized: bool = False) -> str:
"""Send a chat message visible to all viewers.
The message body is HTML-escaped by default to prevent injection
when echoing user-originated content. Markdown syntax is unaffected
by escaping and will be rendered normally by Owncast.
:param body: The message text (supports markdown).
:param unsanitized: If ``True``, skip HTML escaping and send the body
as-is. Only use this when the content is fully controlled by the
module and intentionally contains HTML.
:return: Success message from the server.
"""
if not unsanitized:
body = str(_escape_html(body))
self._logger.info("Sending chat message: %s", body)
return await self._post("/api/integrations/chat/send", {"body": body})
async def send_system_message(self, body: str, *, unsanitized: bool = False) -> str: async def send_system_message(self, body: str, *, unsanitized: bool = False) -> str:
"""Send a system message visible to all viewers. """Send a system message visible to all viewers.
@@ -106,24 +77,6 @@ class OwncastClient:
self._logger.info("Sending system message: %s", body) self._logger.info("Sending system message: %s", body)
return await self._post("/api/integrations/chat/system", {"body": body}) return await self._post("/api/integrations/chat/system", {"body": body})
async def send_action(self, body: str, *, unsanitized: bool = False) -> str:
"""Send an action message (like IRC /me).
Action messages display as "*BotName does something*" and are used
for describing actions rather than speech.
The message body is HTML-escaped by default. Pass
``unsanitized=True`` to send raw HTML intentionally.
:param body: The action text (displayed after the bot name).
:param unsanitized: If ``True``, skip HTML escaping.
:return: Success message from the server.
"""
if not unsanitized:
body = str(_escape_html(body))
self._logger.info("Sending action: %s", body)
return await self._post("/api/integrations/chat/action", {"body": body})
async def send_system_message_to_client( async def send_system_message_to_client(
self, client_id: int, body: str, *, unsanitized: bool = False self, client_id: int, body: str, *, unsanitized: bool = False
) -> str: ) -> str:
@@ -147,6 +100,55 @@ class OwncastClient:
f"/api/integrations/chat/system/client/{client_id}", {"body": body} f"/api/integrations/chat/system/client/{client_id}", {"body": body}
) )
async def send_user_message(self) -> str:
"""Send a chat message on behalf of a user (deprecated by Owncast).
Owncast no longer supports this endpoint as of v0.2.5: it always returns
HTTP 400 with a message directing callers to :meth:`send_message`. The
wrapper is provided for spec parity. Use :meth:`send_message` instead.
:return: Success message from the server (never returned in practice).
:raises OwncastError: Always, with status 400.
"""
self._logger.info("Calling deprecated /integrations/chat/user endpoint.")
return await self._post("/api/integrations/chat/user")
async def send_message(self, body: str, *, unsanitized: bool = False) -> str:
"""Send a chat message visible to all viewers.
The message body is HTML-escaped by default to prevent injection
when echoing user-originated content. Markdown syntax is unaffected
by escaping and will be rendered normally by Owncast.
:param body: The message text (supports markdown).
:param unsanitized: If ``True``, skip HTML escaping and send the body
as-is. Only use this when the content is fully controlled by the
module and intentionally contains HTML.
:return: Success message from the server.
"""
if not unsanitized:
body = str(_escape_html(body))
self._logger.info("Sending chat message: %s", body)
return await self._post("/api/integrations/chat/send", {"body": body})
async def send_action(self, body: str, *, unsanitized: bool = False) -> str:
"""Send an action message (like IRC /me).
Action messages display as "*BotName does something*" and are used
for describing actions rather than speech.
The message body is HTML-escaped by default. Pass
``unsanitized=True`` to send raw HTML intentionally.
:param body: The action text (displayed after the bot name).
:param unsanitized: If ``True``, skip HTML escaping.
:return: Success message from the server.
"""
if not unsanitized:
body = str(_escape_html(body))
self._logger.info("Sending action: %s", body)
return await self._post("/api/integrations/chat/action", {"body": body})
async def set_message_visibility( async def set_message_visibility(
self, message_ids: list[str], *, visible: bool self, message_ids: list[str], *, visible: bool
) -> str: ) -> str:
@@ -167,6 +169,26 @@ class OwncastClient:
{"idArray": message_ids, "visible": visible}, {"idArray": message_ids, "visible": visible},
) )
async def get_status(self) -> dict[str, Any]:
"""Get the public server status.
This is a public endpoint that does not require authentication.
Returns server info including version, online status, and viewer count.
:return: Status dict with ``versionNumber``, ``online``, ``viewerCount``, etc.
"""
self._logger.debug("Fetching server status.")
return dict(await self._get("/api/status"))
async def set_stream_title(self, title: str) -> str:
"""Update the stream title.
:param title: The new stream title.
:return: Success message from the server.
"""
self._logger.info("Setting stream title: %s", title)
return await self._post("/api/integrations/streamtitle", {"value": title})
async def get_chat_history(self) -> list[dict[str, Any]]: async def get_chat_history(self) -> list[dict[str, Any]]:
"""Fetch recent chat messages. """Fetch recent chat messages.
@@ -183,21 +205,23 @@ class OwncastClient:
self._logger.debug("Fetching connected clients.") self._logger.debug("Fetching connected clients.")
return list(await self._get("/api/integrations/clients")) return list(await self._get("/api/integrations/clients"))
async def set_stream_title(self, title: str) -> str: async def get_user_details(self, user_id: str) -> dict[str, Any]:
"""Update the stream title. """Get details for a chat user.
:param title: The new stream title. :param user_id: The user ID to look up.
:return: Success message from the server. :return: Dict with ``user``, ``connectedClients``, and ``messages`` fields.
""" """
self._logger.info("Setting stream title: %s", title) self._logger.debug("Fetching user details for %s.", user_id)
return await self._post("/api/integrations/streamtitle", {"value": title}) return dict(
await self._get(f"/api/integrations/moderation/chat/user/{user_id}")
)
async def _post(self, endpoint: str, data: dict[str, Any] | None = None) -> str: async def _post(self, endpoint: str, data: dict[str, Any] | None = None) -> str:
"""Send a POST request to the Owncast API. """Send a POST request to the Owncast API.
:raises OwncastError: If the request fails. :raises OwncastError: If the request fails.
""" """
return await post_with_envelope( return await request_with_envelope(
self._http, self._http,
self._base_url, self._base_url,
endpoint, endpoint,
+93 -30
View File
@@ -63,26 +63,108 @@ def _extract_error(text: str) -> str:
return text return text
async def post_with_envelope( async def request_with_envelope(
http: HttpClient, http: HttpClient,
base_url: str, base_url: str,
endpoint: str, endpoint: str,
data: dict[str, Any] | None = None, data: dict[str, Any] | aiohttp.FormData | None = None,
*,
logger: logging.Logger,
auth: aiohttp.BasicAuth | None = None,
headers: dict[str, str] | None = None,
method: str = "POST",
) -> str:
"""Send a body-carrying request and unwrap the Owncast envelope.
The Owncast envelope is the ``{"success", "message"}`` JSON object
returned by most admin and integration endpoints.
:param data: Request body. A ``dict`` is sent as JSON; an
:class:`aiohttp.FormData` is sent as ``multipart/form-data`` with
aiohttp picking the boundary. ``None`` sends no body.
:param method: HTTP method to use (``POST`` by default; also ``PUT``,
``DELETE``, ``PATCH``).
:raises OwncastError: If the request fails or the server reports failure.
"""
url = f"{base_url}{endpoint}"
logger.debug("%s %s", method, endpoint)
request_kwargs: dict[str, Any] = (
{"data": data} if isinstance(data, aiohttp.FormData) else {"json": data}
)
try:
async with http.session.request(
method,
url,
headers=headers,
auth=auth,
allow_redirects=False,
**request_kwargs,
) as response:
if response.status >= HTTPStatus.BAD_REQUEST:
text = await response.text()
message = _extract_error(text)
logger.error(
"Error %d on %s %s: %s",
response.status,
method,
endpoint,
message,
)
raise OwncastError(response.status, message)
logger.debug("%s %s -> %d", method, endpoint, response.status)
if response.content_type == "application/json":
try:
result = await response.json(loads=orjson.loads)
except (ValueError, ContentTypeError):
text = await response.text()
logger.exception(
"Invalid JSON on %s %s: %s", method, endpoint, text
)
raise OwncastError(response.status, text) from None
if isinstance(result, dict):
if "error" in result:
logger.error(
"Error on %s %s: %s", method, endpoint, result["error"]
)
raise OwncastError(response.status, result["error"])
if "success" in result and not result["success"]:
message = result.get("message", "unknown error")
logger.error("Error on %s %s: %s", method, endpoint, message)
raise OwncastError(response.status, message)
if "success" in result:
return str(result.get("message", ""))
logger.warning(
"Unknown response on %s %s: %s", method, endpoint, result
)
return ""
return ""
except aiohttp.ClientError as e:
logger.exception("Connection error on %s %s.", method, endpoint)
raise OwncastError(0, str(e)) from e
async def get_text(
http: HttpClient,
base_url: str,
endpoint: str,
params: dict[str, Any] | None = None,
*, *,
logger: logging.Logger, logger: logging.Logger,
auth: aiohttp.BasicAuth | None = None, auth: aiohttp.BasicAuth | None = None,
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
) -> str: ) -> str:
"""Send a POST and unwrap the Owncast ``{"success", "message"}`` envelope. """Send a GET and return the raw response body as text.
:raises OwncastError: If the request fails or the server reports failure. Used for endpoints that return non-JSON payloads (e.g. Prometheus metrics).
:raises OwncastError: If the request fails.
""" """
url = f"{base_url}{endpoint}" url = f"{base_url}{endpoint}"
logger.debug("POST %s", endpoint) logger.debug("GET %s", endpoint)
try: try:
async with http.session.post( async with http.session.get(
url, url,
json=data, params=params,
headers=headers, headers=headers,
auth=auth, auth=auth,
allow_redirects=False, allow_redirects=False,
@@ -91,32 +173,13 @@ async def post_with_envelope(
text = await response.text() text = await response.text()
message = _extract_error(text) message = _extract_error(text)
logger.error( logger.error(
"Error %d on POST %s: %s", response.status, endpoint, message "Error %d on GET %s: %s", response.status, endpoint, message
) )
raise OwncastError(response.status, message) raise OwncastError(response.status, message)
logger.debug("POST %s -> %d", endpoint, response.status) logger.debug("GET %s -> %d", endpoint, response.status)
if response.content_type == "application/json": return await response.text()
try:
result = await response.json(loads=orjson.loads)
except (ValueError, ContentTypeError):
text = await response.text()
logger.exception("Invalid JSON on POST %s: %s", endpoint, text)
raise OwncastError(response.status, text) from None
if isinstance(result, dict):
if "error" in result:
logger.error("Error on POST %s: %s", endpoint, result["error"])
raise OwncastError(response.status, result["error"])
if "success" in result and not result["success"]:
message = result.get("message", "unknown error")
logger.error("Error on POST %s: %s", endpoint, message)
raise OwncastError(response.status, message)
if "success" in result:
return str(result.get("message", ""))
logger.warning("Unknown response on POST %s: %s", endpoint, result)
return ""
return ""
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
logger.exception("Connection error on POST %s.", endpoint) logger.exception("Connection error on GET %s.", endpoint)
raise OwncastError(0, str(e)) from e raise OwncastError(0, str(e)) from e
+333 -253
View File
@@ -67,18 +67,6 @@ class RecordingOwncastClient(_ClientBase):
"""The Owncast server base URL passed at construction.""" """The Owncast server base URL passed at construction."""
return self._base_url 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: async def send_system_message(self, body: str, *, unsanitized: bool = False) -> str:
"""Send a system message visible to all viewers.""" """Send a system message visible to all viewers."""
self.calls.append( self.calls.append(
@@ -88,13 +76,6 @@ class RecordingOwncastClient(_ClientBase):
) )
return "" 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( async def send_system_message_to_client(
self, client_id: int, body: str, *, unsanitized: bool = False self, client_id: int, body: str, *, unsanitized: bool = False
) -> str: ) -> str:
@@ -107,6 +88,25 @@ class RecordingOwncastClient(_ClientBase):
) )
return "" return ""
async def send_user_message(self) -> str:
"""Send a chat message on behalf of a user (deprecated by Owncast)."""
self.calls.append(RecordedCall("send_user_message", {}))
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_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 set_message_visibility( async def set_message_visibility(
self, message_ids: list[str], *, visible: bool self, message_ids: list[str], *, visible: bool
) -> str: ) -> str:
@@ -119,6 +119,16 @@ class RecordingOwncastClient(_ClientBase):
) )
return "" return ""
async def get_status(self) -> dict[str, Any]:
"""Get the public server status."""
self.calls.append(RecordedCall("get_status", {}))
return {}
async def set_stream_title(self, title: str) -> str:
"""Update the stream title."""
self.calls.append(RecordedCall("set_stream_title", {"title": title}))
return ""
async def get_chat_history(self) -> list[dict[str, Any]]: async def get_chat_history(self) -> list[dict[str, Any]]:
"""Fetch recent chat messages.""" """Fetch recent chat messages."""
self.calls.append(RecordedCall("get_chat_history", {})) self.calls.append(RecordedCall("get_chat_history", {}))
@@ -129,10 +139,10 @@ class RecordingOwncastClient(_ClientBase):
self.calls.append(RecordedCall("get_connected_clients", {})) self.calls.append(RecordedCall("get_connected_clients", {}))
return [] return []
async def set_stream_title(self, title: str) -> str: async def get_user_details(self, user_id: str) -> dict[str, Any]:
"""Update the stream title.""" """Get moderation details for a chat user."""
self.calls.append(RecordedCall("set_stream_title", {"title": title})) self.calls.append(RecordedCall("get_user_details", {"user_id": user_id}))
return "" return {}
class RecordingOwncastAdminClient(_AdminBase): class RecordingOwncastAdminClient(_AdminBase):
@@ -157,10 +167,15 @@ class RecordingOwncastAdminClient(_AdminBase):
self.calls.append(RecordedCall("get_status", {})) self.calls.append(RecordedCall("get_status", {}))
return {} return {}
async def get_active_viewers(self) -> list[dict[str, Any]]: async def disconnect_stream(self) -> dict[str, Any]:
"""Get a list of currently active viewers.""" """Disconnect the current inbound stream."""
self.calls.append(RecordedCall("get_active_viewers", {})) self.calls.append(RecordedCall("disconnect_stream", {}))
return [] 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 get_viewers_over_time(self, window_start: int) -> list[dict[str, Any]]: async def get_viewers_over_time(self, window_start: int) -> list[dict[str, Any]]:
"""Get viewer count data over time for charting.""" """Get viewer count data over time for charting."""
@@ -169,34 +184,19 @@ class RecordingOwncastAdminClient(_AdminBase):
) )
return [] 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_hardware_stats(self) -> dict[str, Any]: async def get_hardware_stats(self) -> dict[str, Any]:
"""Get server hardware statistics (CPU, memory, disk).""" """Get server hardware statistics (CPU, memory, disk)."""
self.calls.append(RecordedCall("get_hardware_stats", {})) self.calls.append(RecordedCall("get_hardware_stats", {}))
return {} return {}
async def get_server_config(self) -> dict[str, Any]: async def get_connected_chat_clients(self) -> list[dict[str, Any]]:
"""Get the full server configuration.""" """Get currently connected chat clients."""
self.calls.append(RecordedCall("get_server_config", {})) self.calls.append(RecordedCall("get_connected_chat_clients", {}))
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 [] return []
async def get_chat_messages(self) -> list[dict[str, Any]]: async def get_chat_messages(self) -> list[dict[str, Any]]:
@@ -204,11 +204,6 @@ class RecordingOwncastAdminClient(_AdminBase):
self.calls.append(RecordedCall("get_chat_messages", {})) self.calls.append(RecordedCall("get_chat_messages", {}))
return [] 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( async def set_message_visibility(
self, message_ids: list[str], *, visible: bool self, message_ids: list[str], *, visible: bool
) -> str: ) -> str:
@@ -233,18 +228,6 @@ class RecordingOwncastAdminClient(_AdminBase):
self.calls.append(RecordedCall("get_disabled_users", {})) self.calls.append(RecordedCall("get_disabled_users", {}))
return [] 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: async def ban_ip_address(self, ip: str) -> str:
"""Ban an IP address from chat.""" """Ban an IP address from chat."""
self.calls.append(RecordedCall("ban_ip_address", {"ip": ip})) self.calls.append(RecordedCall("ban_ip_address", {"ip": ip}))
@@ -260,6 +243,84 @@ class RecordingOwncastAdminClient(_AdminBase):
self.calls.append(RecordedCall("get_ip_address_bans", {})) self.calls.append(RecordedCall("get_ip_address_bans", {}))
return [] 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 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_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 upload_emoji(self, name: str, base64_data_url: str) -> str:
"""Upload a custom emoji."""
self.calls.append(
RecordedCall(
"upload_emoji",
{"name": name, "base64_data_url": base64_data_url},
)
)
return ""
async def delete_emoji(self, name: str) -> str:
"""Delete a custom emoji."""
self.calls.append(RecordedCall("delete_emoji", {"name": name}))
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_stream_keys(self, keys: list[StreamKey]) -> str:
"""Set the stream keys."""
self.calls.append(RecordedCall("set_stream_keys", {"keys": keys}))
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_stream_title(self, title: str) -> str: async def set_stream_title(self, title: str) -> str:
"""Set the stream title.""" """Set the stream title."""
self.calls.append(RecordedCall("set_stream_title", {"title": title})) self.calls.append(RecordedCall("set_stream_title", {"title": title}))
@@ -275,66 +336,14 @@ class RecordingOwncastAdminClient(_AdminBase):
self.calls.append(RecordedCall("set_server_summary", {"summary": summary})) self.calls.append(RecordedCall("set_server_summary", {"summary": summary}))
return "" 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: async def set_offline_message(self, message: str) -> str:
"""Set the message shown when the stream is offline.""" """Set the message shown when the stream is offline."""
self.calls.append(RecordedCall("set_offline_message", {"message": message})) self.calls.append(RecordedCall("set_offline_message", {"message": message}))
return "" return ""
async def set_page_content(self, content: str) -> str: async def set_welcome_message(self, message: str) -> str:
"""Set the custom page content (HTML/markdown below the player).""" """Set the welcome message shown to new viewers."""
self.calls.append(RecordedCall("set_page_content", {"content": content})) self.calls.append(RecordedCall("set_welcome_message", {"message": message}))
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 "" return ""
async def set_chat_disabled(self, *, disabled: bool) -> str: async def set_chat_disabled(self, *, disabled: bool) -> str:
@@ -356,6 +365,16 @@ class RecordingOwncastAdminClient(_AdminBase):
) )
return "" 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_chat_spam_protection(self, *, enabled: bool) -> str: async def set_chat_spam_protection(self, *, enabled: bool) -> str:
"""Enable or disable chat spam protection.""" """Enable or disable chat spam protection."""
self.calls.append( self.calls.append(
@@ -368,14 +387,87 @@ class RecordingOwncastAdminClient(_AdminBase):
self.calls.append(RecordedCall("set_chat_slur_filter", {"enabled": enabled})) self.calls.append(RecordedCall("set_chat_slur_filter", {"enabled": enabled}))
return "" return ""
async def set_forbidden_usernames(self, names: list[str]) -> str: async def set_chat_require_authentication(self, *, required: bool) -> str:
"""Set the list of forbidden usernames.""" """Require chat users to authenticate before sending messages."""
self.calls.append(RecordedCall("set_forbidden_usernames", {"names": names})) self.calls.append(
RecordedCall("set_chat_require_authentication", {"required": required})
)
return "" return ""
async def set_suggested_usernames(self, names: list[str]) -> str: async def set_video_codec(self, codec: str) -> str:
"""Set the list of suggested usernames for new viewers.""" """Set the video codec."""
self.calls.append(RecordedCall("set_suggested_usernames", {"names": names})) self.calls.append(RecordedCall("set_video_codec", {"codec": codec}))
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_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_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_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_favicon(self, base64_data_url: str) -> str:
"""Set a custom favicon from a base64 data URL."""
self.calls.append(
RecordedCall("set_favicon", {"base64_data_url": base64_data_url})
)
return ""
async def reset_favicon(self) -> str:
"""Reset the favicon to the Owncast default."""
self.calls.append(RecordedCall("reset_favicon", {}))
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_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_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_rtmp_port(self, port: int) -> str:
"""Set the RTMP server port."""
self.calls.append(RecordedCall("set_rtmp_port", {"port": port}))
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_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 "" return ""
async def set_nsfw(self, *, nsfw: bool) -> str: async def set_nsfw(self, *, nsfw: bool) -> str:
@@ -388,68 +480,9 @@ class RecordingOwncastAdminClient(_AdminBase):
self.calls.append(RecordedCall("set_directory_enabled", {"enabled": enabled})) self.calls.append(RecordedCall("set_directory_enabled", {"enabled": enabled}))
return "" return ""
async def set_hide_viewer_count(self, *, hide: bool) -> str: async def set_social_handles(self, handles: list[SocialHandle]) -> str:
"""Show or hide the viewer count.""" """Set the social media handles displayed on the page."""
self.calls.append(RecordedCall("set_hide_viewer_count", {"hide": hide})) self.calls.append(RecordedCall("set_social_handles", {"handles": handles}))
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 "" return ""
async def set_s3_config( async def set_s3_config(
@@ -478,6 +511,76 @@ class RecordingOwncastAdminClient(_AdminBase):
) )
return "" 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_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_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_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_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_activity_private(self, *, private: bool) -> str:
"""Mark federated activity as private (followers-only)."""
self.calls.append(
RecordedCall("set_federation_activity_private", {"private": private})
)
return ""
async def set_federation_show_engagement(self, *, enabled: bool) -> str:
"""Show or hide federated engagement (likes, boosts) in chat."""
self.calls.append(
RecordedCall("set_federation_show_engagement", {"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 ""
async def set_discord_notifications( async def set_discord_notifications(
self, *, enabled: bool, webhook: str, go_live_message: str self, *, enabled: bool, webhook: str, go_live_message: str
) -> str: ) -> str:
@@ -506,23 +609,16 @@ class RecordingOwncastAdminClient(_AdminBase):
) )
return "" 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]]: async def get_webhooks(self) -> list[dict[str, Any]]:
"""Get all registered webhooks.""" """Get all registered webhooks."""
self.calls.append(RecordedCall("get_webhooks", {})) self.calls.append(RecordedCall("get_webhooks", {}))
return [] 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 create_webhook(self, url: str, events: list[str]) -> str: async def create_webhook(self, url: str, events: list[str]) -> str:
"""Create a new webhook.""" """Create a new webhook."""
self.calls.append( self.calls.append(
@@ -530,16 +626,16 @@ class RecordingOwncastAdminClient(_AdminBase):
) )
return "" 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]]: async def get_access_tokens(self) -> list[dict[str, Any]]:
"""Get all access tokens.""" """Get all access tokens."""
self.calls.append(RecordedCall("get_access_tokens", {})) self.calls.append(RecordedCall("get_access_tokens", {}))
return [] 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 create_access_token(self, name: str, scopes: list[str]) -> str: async def create_access_token(self, name: str, scopes: list[str]) -> str:
"""Create a new access token.""" """Create a new access token."""
self.calls.append( self.calls.append(
@@ -547,35 +643,34 @@ class RecordingOwncastAdminClient(_AdminBase):
) )
return "" return ""
async def delete_access_token(self, token: str) -> str: async def reset_yp_registration(self) -> dict[str, Any]:
"""Delete an access token.""" """Clear the YP (Owncast directory) registration key."""
self.calls.append(RecordedCall("delete_access_token", {"token": token})) self.calls.append(RecordedCall("reset_yp_registration", {}))
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 {} return {}
async def get_pending_follow_requests(self) -> list[dict[str, Any]]: async def get_playback_metrics(self) -> dict[str, Any]:
"""Get pending follow requests.""" """Get playback quality metrics."""
self.calls.append(RecordedCall("get_pending_follow_requests", {})) self.calls.append(RecordedCall("get_playback_metrics", {}))
return [] return {}
async def get_blocked_followers(self) -> list[dict[str, Any]]: async def get_prometheus_metrics(self) -> str:
"""Get blocked and rejected followers.""" """Fetch metrics from the Owncast-proxied Prometheus endpoint."""
self.calls.append(RecordedCall("get_blocked_followers", {})) self.calls.append(RecordedCall("get_prometheus_metrics", {}))
return [] return ""
async def approve_follower(self, actor_iri: str, *, approved: bool) -> str: async def post_prometheus(self, data: dict[str, Any] | None = None) -> str:
"""Approve or reject a follow request.""" """POST to the Owncast-proxied Prometheus endpoint."""
self.calls.append( self.calls.append(RecordedCall("post_prometheus", {"data": data}))
RecordedCall( return ""
"approve_follower", {"actor_iri": actor_iri, "approved": approved}
) async def put_prometheus(self, data: dict[str, Any] | None = None) -> str:
) """PUT to the Owncast-proxied Prometheus endpoint."""
self.calls.append(RecordedCall("put_prometheus", {"data": data}))
return ""
async def delete_prometheus(self) -> str:
"""DELETE on the Owncast-proxied Prometheus endpoint."""
self.calls.append(RecordedCall("delete_prometheus", {}))
return "" return ""
async def send_federated_message(self, message: str) -> str: async def send_federated_message(self, message: str) -> str:
@@ -583,26 +678,11 @@ class RecordingOwncastAdminClient(_AdminBase):
self.calls.append(RecordedCall("send_federated_message", {"message": message})) self.calls.append(RecordedCall("send_federated_message", {"message": message}))
return "" return ""
async def set_federation_enabled(self, *, enabled: bool) -> str: async def get_federated_actions(
"""Enable or disable federation (ActivityPub).""" self, offset: int = 0, limit: int = 25
self.calls.append(RecordedCall("set_federation_enabled", {"enabled": enabled})) ) -> dict[str, Any]:
return "" """Get a paginated list of accepted inbound federated activities."""
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( self.calls.append(
RecordedCall("set_federation_go_live_message", {"message": message}) RecordedCall("get_federated_actions", {"offset": offset, "limit": limit})
) )
return "" 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 ""
+3
View File
@@ -77,6 +77,9 @@ explicit_package_bases = true
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function" asyncio_default_fixture_loop_scope = "function"
markers = [
"integration: end-to-end tests that require a live Owncast binary",
]
[tool.ruff] [tool.ruff]
target-version = "py312" target-version = "py312"
+29
View File
@@ -20,4 +20,33 @@ loaded via ``pytest_plugins`` below.
from __future__ import annotations 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"] 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 from __future__ import annotations
import inspect 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 import pytest
@@ -47,6 +48,10 @@ def _synthesize_value(param_name: str, annotation: Any) -> Any:
originally-passed kwargs. originally-passed kwargs.
""" """
origin = get_origin(annotation) 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: if origin is list:
return [] return []
if origin is dict: if origin is dict: