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
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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
+624
-465
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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 ""
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Vendored
+4570
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
"""End-to-end integration tests for the Owncast API clients."""
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user