Refactored OwncastSentry internals and API validation.
This commit is contained in:
+231
-27
@@ -22,7 +22,12 @@ import pytest
|
||||
from aioresponses import aioresponses
|
||||
|
||||
from owncastsentry.metrics import MetricsService
|
||||
from owncastsentry.owncast_client import OwncastClient
|
||||
from owncastsentry.owncast_client import (
|
||||
_MAX_JSON_RESPONSE_BYTES,
|
||||
OwncastClient,
|
||||
_read_limited_response_body,
|
||||
_user_agent,
|
||||
)
|
||||
from tests.conftest import (
|
||||
VALID_CONFIG_RESPONSE,
|
||||
VALID_STATUS_RESPONSE,
|
||||
@@ -33,6 +38,32 @@ if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
class _ChunkedContent:
|
||||
"""Fake aiohttp response content that yields predefined chunks."""
|
||||
|
||||
def __init__(self, chunks: tuple[bytes, ...]) -> None:
|
||||
"""Store chunks to return from iter_chunked."""
|
||||
self._chunks = chunks
|
||||
|
||||
async def iter_chunked(self, size: int) -> AsyncIterator[bytes]:
|
||||
"""Yield chunks using the interface aiohttp exposes."""
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
class _ChunkedResponse:
|
||||
"""Fake aiohttp response with chunked content."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chunks: tuple[bytes, ...],
|
||||
content_length: int | None = None,
|
||||
) -> None:
|
||||
"""Store the content stream and optional Content-Length value."""
|
||||
self.content = _ChunkedContent(chunks)
|
||||
self.content_length = content_length
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def owncast_client() -> AsyncIterator[OwncastClient]:
|
||||
"""Create an OwncastClient and close it after the test."""
|
||||
@@ -45,6 +76,79 @@ async def owncast_client() -> AsyncIterator[OwncastClient]:
|
||||
await client.close()
|
||||
|
||||
|
||||
class TestUserAgent:
|
||||
"""User-Agent header construction."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("version", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
"1.2.3",
|
||||
"OwncastSentry/1.2.3 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="semver",
|
||||
),
|
||||
pytest.param(
|
||||
"0.0.0",
|
||||
"OwncastSentry/0.0.0 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="zeroed",
|
||||
),
|
||||
pytest.param(
|
||||
"1.1.1.dev10+gf0146d061.d20260313",
|
||||
"OwncastSentry/1.1.1.dev10+gf0146d061.d20260313 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="dev-version",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_user_agent(self, version: str, expected: str) -> None:
|
||||
"""Build a correctly formatted User-Agent header."""
|
||||
assert _user_agent(version) == expected
|
||||
|
||||
|
||||
class TestReadLimitedResponseBody:
|
||||
"""Bounded response body reading."""
|
||||
|
||||
async def test_reads_all_chunks_before_returning(self) -> None:
|
||||
"""Return the full body when JSON arrives in multiple chunks."""
|
||||
response = _ChunkedResponse(
|
||||
(
|
||||
b'{"streamTitle":',
|
||||
b'"hello","online":true,',
|
||||
b'"lastConnectTime":null,"lastDisconnectTime":null}',
|
||||
)
|
||||
)
|
||||
|
||||
result = await _read_limited_response_body(response)
|
||||
|
||||
assert result == bytearray(
|
||||
b'{"streamTitle":"hello","online":true,'
|
||||
b'"lastConnectTime":null,"lastDisconnectTime":null}'
|
||||
)
|
||||
|
||||
async def test_returns_none_when_content_length_is_too_large(self) -> None:
|
||||
"""Return None when Content-Length is already over the limit."""
|
||||
response = _ChunkedResponse(
|
||||
(),
|
||||
content_length=_MAX_JSON_RESPONSE_BYTES + 1,
|
||||
)
|
||||
|
||||
result = await _read_limited_response_body(response)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_when_streamed_body_is_too_large(self) -> None:
|
||||
"""Return None when chunked content grows past the limit."""
|
||||
response = _ChunkedResponse(
|
||||
(
|
||||
b"x" * _MAX_JSON_RESPONSE_BYTES,
|
||||
b"x",
|
||||
)
|
||||
)
|
||||
|
||||
result = await _read_limited_response_body(response)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetStreamState:
|
||||
"""Stream state retrieval from the status API."""
|
||||
|
||||
@@ -82,6 +186,23 @@ class TestGetStreamState:
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_invalid_field_type(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response has malformed field types."""
|
||||
malformed = {
|
||||
**VALID_STATUS_RESPONSE,
|
||||
"streamTitle": 123,
|
||||
}
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=json.dumps(malformed).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_invalid_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
@@ -95,6 +216,32 @@ class TestGetStreamState:
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_non_object_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response JSON is not an object."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=json.dumps([]).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_oversized_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response body is too large."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=b" " * (_MAX_JSON_RESPONSE_BYTES + 1),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
|
||||
"""Return None when the response status is not 200."""
|
||||
with aioresponses() as mocked:
|
||||
@@ -136,7 +283,7 @@ class TestGetStreamConfig:
|
||||
|
||||
assert result is not None
|
||||
assert result.name == "LogalDeveloper's Live Stream"
|
||||
assert result.tags == [
|
||||
assert result.tags == (
|
||||
"video games",
|
||||
"chatting",
|
||||
"casual",
|
||||
@@ -144,7 +291,7 @@ class TestGetStreamConfig:
|
||||
"streaming",
|
||||
"owncast",
|
||||
"variety",
|
||||
]
|
||||
)
|
||||
|
||||
async def test_returns_none_on_invalid_json(
|
||||
self, owncast_client: OwncastClient
|
||||
@@ -159,6 +306,49 @@ class TestGetStreamConfig:
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_non_object_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response JSON is not an object."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
body=json.dumps([]).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_invalid_field_type(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response has malformed field types."""
|
||||
malformed = {
|
||||
**VALID_CONFIG_RESPONSE,
|
||||
"tags": "gaming",
|
||||
}
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
body=json.dumps(malformed).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_oversized_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response body is too large."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
body=b" " * (_MAX_JSON_RESPONSE_BYTES + 1),
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
|
||||
"""Return None when the response status is not 200."""
|
||||
with aioresponses() as mocked:
|
||||
@@ -225,15 +415,19 @@ class TestResponseTimeMetrics:
|
||||
version="0.0.0",
|
||||
metrics=metrics,
|
||||
)
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
try:
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert (
|
||||
'owncastsentry_api_response_seconds{domain="example.com"}' in output
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' in output
|
||||
await client.close()
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
async def test_no_observation_on_failure(self) -> None:
|
||||
"""Do not record response time when request fails."""
|
||||
@@ -243,15 +437,20 @@ class TestResponseTimeMetrics:
|
||||
version="0.0.0",
|
||||
metrics=metrics,
|
||||
)
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
status=500,
|
||||
try:
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
status=500,
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert (
|
||||
'owncastsentry_api_response_seconds{domain="example.com"}'
|
||||
not in output
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
||||
await client.close()
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
async def test_no_observation_on_connection_error(self) -> None:
|
||||
"""Do not record response time on connection error."""
|
||||
@@ -261,15 +460,20 @@ class TestResponseTimeMetrics:
|
||||
version="0.0.0",
|
||||
metrics=metrics,
|
||||
)
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
exception=ConnectionError(),
|
||||
try:
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
exception=ConnectionError(),
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert (
|
||||
'owncastsentry_api_response_seconds{domain="example.com"}'
|
||||
not in output
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
||||
await client.close()
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
class TestOpenConnectionCount:
|
||||
|
||||
Reference in New Issue
Block a user