Files
RelayTV-HA/tests/test_relaytv_api.py
markandClaude Fable 5 f2bf565623 Harden RelayTV integration and prepare HACS 0.4.0 (#2)
* docs: add Home Assistant 0.4.0 hardening roadmap

* fix: protect media credentials and resume state

* feat: authenticate RelayTV API operations

* fix: make targeting and media state reliable

* test: add Home Assistant integration CI

* fix: validate API credentials during setup

* release: prepare HACS 0.4.0

* fix: align pytest dependency pin

* fix: close URL sanitizer gaps and correct player state reporting

Sync the sensitive-query-key list with the RelayTV server (adds auth,
exp, jwt, X-Emby-Token, X-Jellyfin-Token), filter query credentials
from relative URLs instead of returning them verbatim, and preserve
brackets around IPv6 literal hosts.

Also report volume on RelayTV's 0-100 scale unconditionally (a raw 1
is 1%, not full volume) and give the coordinator its own
position_updated_at stamp — the base DataUpdateCoordinator has no
last_update_success_time, so media_position_updated_at silently fell
back to now() on every read and the seek bar never extrapolated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:08:54 -05:00

106 lines
2.9 KiB
Python

"""Tests for RelayTV HTTP error and authentication behavior."""
from typing import Any
import pytest
from custom_components.relaytv.relaytv_api import (
RelayTVApi,
RelayTVAuthError,
RelayTVResponseError,
)
class FakeResponse:
"""Minimal aiohttp response context manager."""
def __init__(self, status: int, payload: dict[str, Any]) -> None:
self.status = status
self.payload = payload
self.reason = "test response"
async def __aenter__(self):
return self
async def __aexit__(self, *_args) -> None:
return None
async def json(self, **_kwargs):
return self.payload
async def text(self) -> str:
return str(self.payload)
class FakeSession:
"""Record requests and return predefined responses."""
def __init__(self, responses: list[FakeResponse]) -> None:
self.responses = responses
self.requests: list[dict[str, Any]] = []
def request(self, method: str, url: str, **kwargs):
self.requests.append({"method": method, "url": url, **kwargs})
return self.responses.pop(0)
def post(self, url: str, **kwargs):
return self.request("POST", url, **kwargs)
@pytest.mark.asyncio
async def test_validate_sends_bearer_token() -> None:
session = FakeSession(
[
FakeResponse(200, {"state": "idle"}),
FakeResponse(200, {"ok": True, "token_required": True}),
]
)
api = RelayTVApi(session, "http://relaytv.local:8787", api_token="testing-token")
await api.validate()
assert [request["headers"] for request in session.requests] == [
{"Authorization": "Bearer testing-token"},
{"Authorization": "Bearer testing-token"},
]
@pytest.mark.asyncio
async def test_auth_failure_raises_and_requests_reauth() -> None:
auth_failures: list[bool] = []
session = FakeSession([FakeResponse(401, {"detail": "api token required"})])
api = RelayTVApi(
session,
"http://relaytv.local:8787",
api_token="wrong",
on_auth_failure=lambda: auth_failures.append(True),
)
with pytest.raises(RelayTVAuthError):
await api.pause()
assert auth_failures == [True]
@pytest.mark.asyncio
async def test_server_error_does_not_trigger_playback_fallback() -> None:
session = FakeSession([FakeResponse(503, {"detail": "backend unavailable"})])
api = RelayTVApi(session, "http://relaytv.local:8787")
with pytest.raises(RelayTVResponseError) as err:
await api.playback_play()
assert err.value.status == 503
assert len(session.requests) == 1
@pytest.mark.asyncio
async def test_mute_sets_explicit_boolean() -> None:
session = FakeSession([FakeResponse(200, {"ok": True, "mute": True})])
api = RelayTVApi(session, "http://relaytv.local:8787")
await api.mute(True)
assert session.requests[0]["url"] == "http://relaytv.local:8787/mute"
assert session.requests[0]["json"] == {"set": True}