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:
@@ -20,4 +20,33 @@ loaded via ``pytest_plugins`` below.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
pytest_plugins = ["owlbot.testing.plugin", "pytester"]
|
||||
|
||||
|
||||
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||
"""Register ``--run-integration`` for the integration-test opt-in flag."""
|
||||
parser.addoption(
|
||||
"--run-integration",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Run integration tests that require downloading Owncast",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(
|
||||
config: pytest.Config, items: Iterable[pytest.Item]
|
||||
) -> None:
|
||||
"""Skip tests marked ``integration`` unless ``--run-integration`` is passed."""
|
||||
if config.getoption("--run-integration"):
|
||||
return
|
||||
skip = pytest.mark.skip(reason="requires --run-integration")
|
||||
for item in items:
|
||||
if "integration" in item.keywords:
|
||||
item.add_marker(skip)
|
||||
|
||||
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
|
||||
|
||||
import inspect
|
||||
from typing import Any, get_origin, get_type_hints
|
||||
import types
|
||||
from typing import Any, Union, get_args, get_origin, get_type_hints
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -47,6 +48,10 @@ def _synthesize_value(param_name: str, annotation: Any) -> Any:
|
||||
originally-passed kwargs.
|
||||
"""
|
||||
origin = get_origin(annotation)
|
||||
if origin in (types.UnionType, Union):
|
||||
non_none = [arg for arg in get_args(annotation) if arg is not type(None)]
|
||||
if len(non_none) == 1:
|
||||
return _synthesize_value(param_name, non_none[0])
|
||||
if origin is list:
|
||||
return []
|
||||
if origin is dict:
|
||||
|
||||
Reference in New Issue
Block a user