Files
Anthias/tests/test_github.py
Viktor Petersson 88eb857210 fix(webview): stop Pi 2/3 blank screen from gcc NEON alignment SIGBUS (#3184)
Toolchain source fix: build_qt5.sh gains arm_use_neon=false + a -mno-unaligned-access cross-compiler wrap (strips the faulting NEON :64 block stores), plus Python 3.13 rebuild shims (imp/cgi/pipes). Validated end-to-end on real Pi 2 (A7) and native 32-bit Pi 3 (A53): unfixed crash-loops with a blank screen, fixed is stable across webpage/image/video + a multi-hour soak. Consumed via WebView-v2026.07.1.
2026-07-13 15:38:01 +01:00

495 lines
16 KiB
Python

import logging
from collections.abc import Iterator
from typing import Any
from unittest import mock
from unittest.mock import MagicMock
import pytest
from requests import exceptions as requests_exceptions
from anthias_server.lib import github
logging.disable(logging.CRITICAL)
@pytest.fixture
def redis_data() -> dict[str, str]:
return {}
@pytest.fixture
def fake_redis(redis_data: dict[str, str]) -> MagicMock:
fake = MagicMock()
fake.get.side_effect = redis_data.get
fake.set.side_effect = lambda key, value: redis_data.__setitem__(
key, value
)
fake.expire.side_effect = lambda _key, _ttl: None
return fake
@pytest.fixture
def github_env(fake_redis: MagicMock) -> Iterator[None]:
with mock.patch.object(github, 'r', fake_redis):
yield
def _resp(status_code: int = 200, json_data: Any = None) -> MagicMock:
resp = MagicMock()
resp.status_code = status_code
if json_data is not None:
resp.json.return_value = json_data
if status_code >= 400:
def _raise() -> None:
raise requests_exceptions.HTTPError(
f'{status_code} error', response=resp
)
resp.raise_for_status.side_effect = _raise
else:
resp.raise_for_status.return_value = None
return resp
# ---------------------------------------------------------------------------
# _fetch_latest_release_tag
# ---------------------------------------------------------------------------
def test_fetch_latest_release_tag_cache_hit(
github_env: None, redis_data: dict[str, str]
) -> None:
redis_data[github.LATEST_RELEASE_TAG_KEY] = 'v2026.5.0'
with mock.patch.object(github, 'requests_get') as mock_get:
assert github._fetch_latest_release_tag() == 'v2026.5.0'
mock_get.assert_not_called()
def test_fetch_latest_release_tag_backoff_skips_fetch(
github_env: None, redis_data: dict[str, str]
) -> None:
redis_data['github-api-error'] = 'something'
with mock.patch.object(github, 'requests_get') as mock_get:
assert github._fetch_latest_release_tag() is None
mock_get.assert_not_called()
def test_fetch_latest_release_tag_happy_path(
github_env: None, redis_data: dict[str, str]
) -> None:
resp = _resp(200, json_data=[{'tag_name': 'v2026.6.0', 'name': 'Release'}])
with mock.patch.object(
github, 'requests_get', return_value=resp
) as mock_get:
assert github._fetch_latest_release_tag() == 'v2026.6.0'
assert redis_data[github.LATEST_RELEASE_TAG_KEY] == 'v2026.6.0'
url = mock_get.call_args.args[0]
assert '/repos/Screenly/Anthias/releases' in url
assert '/releases/latest' not in url
def test_fetch_latest_release_tag_skips_non_app_releases(
github_env: None, redis_data: dict[str, str]
) -> None:
"""The ANTHIAS-3P scenario: a non-app release (frozen Qt 5
toolchain, tagged ``WebView-v2026.07.1``) is the newest release in
the repo. ``/releases/latest`` would return it and break the
update check fleet-wide; the list-based fetch must skip it and
pick the newest parseable CalVer tag instead."""
resp = _resp(
200,
json_data=[
{'tag_name': 'WebView-v2026.07.1', 'name': 'WebView toolchain'},
{'tag_name': 'v2026.7.0', 'name': 'Anthias'},
{'tag_name': 'v2026.6.3', 'name': 'Anthias'},
],
)
with mock.patch.object(github, 'requests_get', return_value=resp):
assert github._fetch_latest_release_tag() == 'v2026.7.0'
assert redis_data[github.LATEST_RELEASE_TAG_KEY] == 'v2026.7.0'
assert 'github-api-error' not in redis_data
def test_fetch_latest_release_tag_picks_highest_version_not_first(
github_env: None, redis_data: dict[str, str]
) -> None:
"""The list is ordered by creation date, not by version — a
hotfix tagged out of order must not mask the real latest."""
resp = _resp(
200,
json_data=[
{'tag_name': 'v2026.5.1', 'name': 'Hotfix, created last'},
{'tag_name': 'v2026.6.0', 'name': 'Anthias'},
],
)
with mock.patch.object(github, 'requests_get', return_value=resp):
assert github._fetch_latest_release_tag() == 'v2026.6.0'
def test_fetch_latest_release_tag_skips_drafts_and_prereleases(
github_env: None, redis_data: dict[str, str]
) -> None:
resp = _resp(
200,
json_data=[
{'tag_name': 'v2026.8.0', 'draft': True},
{'tag_name': 'v2026.7.1', 'prerelease': True},
{'tag_name': 'v2026.7.0'},
],
)
with mock.patch.object(github, 'requests_get', return_value=resp):
assert github._fetch_latest_release_tag() == 'v2026.7.0'
def test_fetch_latest_release_tag_request_exception(
github_env: None, redis_data: dict[str, str]
) -> None:
with mock.patch.object(
github,
'requests_get',
side_effect=requests_exceptions.ConnectionError(),
):
assert github._fetch_latest_release_tag() is None
assert 'github-api-error' in redis_data
def test_fetch_latest_release_tag_5xx_triggers_backoff(
github_env: None, redis_data: dict[str, str]
) -> None:
with mock.patch.object(github, 'requests_get', return_value=_resp(500)):
assert github._fetch_latest_release_tag() is None
assert 'github-api-error' in redis_data
def test_fetch_latest_release_tag_invalid_json(
github_env: None, redis_data: dict[str, str]
) -> None:
resp = _resp(200)
resp.json.side_effect = ValueError('bad json')
with mock.patch.object(github, 'requests_get', return_value=resp):
assert github._fetch_latest_release_tag() is None
# Malformed bodies arm the same backoff as transport failures so
# the next page render doesn't re-fetch immediately.
assert 'github-api-error' in redis_data
assert github.LATEST_RELEASE_TAG_KEY not in redis_data
@pytest.mark.parametrize(
'json_data',
[
[],
[{'name': 'Release without tag_name'}],
[{'tag_name': 12345}],
[{'tag_name': 'nightly'}, {'tag_name': 'WebView-v2026.07.1'}],
{'tag_name': 'v2026.6.0'}, # dict (old /latest shape), not a list
],
ids=[
'empty-list',
'missing-tag-name',
'non-string-tag-name',
'no-parseable-tags',
'non-list-payload',
],
)
def test_fetch_latest_release_tag_unusable_payload_arms_backoff(
github_env: None, redis_data: dict[str, str], json_data: object
) -> None:
"""A payload with no usable tag must not be cached: with a 24h
TTL, caching would pin is_up_to_date() to the fallback verdict
for a day even after upstream corrects it. Trip the 5-minute
backoff instead so the next attempt re-fetches once upstream is
fixed."""
resp = _resp(200, json_data=json_data)
with mock.patch.object(github, 'requests_get', return_value=resp):
assert github._fetch_latest_release_tag() is None
assert 'github-api-error' in redis_data
assert github.LATEST_RELEASE_TAG_KEY not in redis_data
# ---------------------------------------------------------------------------
# _parse_version
# ---------------------------------------------------------------------------
def test_parse_version_strips_leading_v() -> None:
a = github._parse_version('v2026.5.0')
b = github._parse_version('2026.5.0')
assert a is not None and b is not None
assert a == b
def test_parse_version_invalid_returns_none() -> None:
assert github._parse_version('') is None
assert github._parse_version('not-a-version') is None
def test_parse_version_calver_ordering_is_numeric() -> None:
"""Catches the bug from the issue: string compare would put 10
before 5; packaging.version must compare numerically."""
assert github._parse_version('2026.10.0') > github._parse_version( # type: ignore[operator]
'2026.5.0'
)
# ---------------------------------------------------------------------------
# is_up_to_date
# ---------------------------------------------------------------------------
def test_is_up_to_date_matching_versions_returns_true(
github_env: None, redis_data: dict[str, str]
) -> None:
with (
mock.patch.object(
github, 'get_anthias_release', return_value='2026.5.0'
),
mock.patch.object(
github, '_fetch_latest_release_tag', return_value='v2026.5.0'
),
):
assert github.is_up_to_date() is True
assert redis_data[github._verdict_cache_key('2026.5.0')] == '1'
def test_is_up_to_date_local_behind_returns_false(
github_env: None, redis_data: dict[str, str]
) -> None:
with (
mock.patch.object(
github, 'get_anthias_release', return_value='2026.5.0'
),
mock.patch.object(
github, '_fetch_latest_release_tag', return_value='v2026.6.0'
),
):
assert github.is_up_to_date() is False
assert redis_data[github._verdict_cache_key('2026.5.0')] == '0'
def test_is_up_to_date_local_ahead_returns_true(
github_env: None, redis_data: dict[str, str]
) -> None:
"""A local dev bump (e.g. master tip past the last release tag)
is still 'up to date' — the indicator is about being behind, not
matching exactly."""
with (
mock.patch.object(
github, 'get_anthias_release', return_value='2026.10.0'
),
mock.patch.object(
github, '_fetch_latest_release_tag', return_value='v2026.5.0'
),
):
assert github.is_up_to_date() is True
assert redis_data[github._verdict_cache_key('2026.10.0')] == '1'
def test_is_up_to_date_unparseable_local_suppresses_indicator(
github_env: None,
) -> None:
"""Dev builds without a parseable CalVer get no comparison and no
pill. The remote fetch isn't even attempted in that case."""
with (
mock.patch.object(github, 'get_anthias_release', return_value=''),
mock.patch.object(github, '_fetch_latest_release_tag') as fetch_mock,
):
assert github.is_up_to_date() is True
fetch_mock.assert_not_called()
def test_is_up_to_date_unparseable_local_string_suppresses_indicator(
github_env: None,
) -> None:
with (
mock.patch.object(
github, 'get_anthias_release', return_value='dev-snapshot'
),
mock.patch.object(github, '_fetch_latest_release_tag') as fetch_mock,
):
assert github.is_up_to_date() is True
fetch_mock.assert_not_called()
def test_is_up_to_date_github_error_with_cached_verdict_uses_it(
github_env: None, redis_data: dict[str, str]
) -> None:
redis_data[github._verdict_cache_key('2026.5.0')] = '1'
with (
mock.patch.object(
github, 'get_anthias_release', return_value='2026.5.0'
),
mock.patch.object(
github, '_fetch_latest_release_tag', return_value=None
),
):
assert github.is_up_to_date() is True
redis_data[github._verdict_cache_key('2026.5.0')] = '0'
with (
mock.patch.object(
github, 'get_anthias_release', return_value='2026.5.0'
),
mock.patch.object(
github, '_fetch_latest_release_tag', return_value=None
),
):
assert github.is_up_to_date() is False
def test_is_up_to_date_verdict_cache_does_not_leak_across_releases(
github_env: None, redis_data: dict[str, str]
) -> None:
"""An upgrade during a GitHub outage must not reuse the previous
version's verdict — that verdict was computed against the OLD
installed release, so it can be stale either way after an
upgrade. Without a cached verdict for the new release, fall back
to False so the indicator state catches up to reality on the next
successful check."""
redis_data[github._verdict_cache_key('2026.5.0')] = '0'
with (
mock.patch.object(
github, 'get_anthias_release', return_value='2026.6.0'
),
mock.patch.object(
github, '_fetch_latest_release_tag', return_value=None
),
):
assert github.is_up_to_date() is False
def test_is_up_to_date_github_error_no_cache_returns_false(
github_env: None,
) -> None:
"""First-run fail-pessimistic: don't claim 'up to date' when we
have never successfully checked."""
with (
mock.patch.object(
github, 'get_anthias_release', return_value='2026.5.0'
),
mock.patch.object(
github, '_fetch_latest_release_tag', return_value=None
),
):
assert github.is_up_to_date() is False
def test_is_up_to_date_malformed_remote_tag_falls_back(
github_env: None, redis_data: dict[str, str]
) -> None:
redis_data[github._verdict_cache_key('2026.5.0')] = '1'
with (
mock.patch.object(
github, 'get_anthias_release', return_value='2026.5.0'
),
mock.patch.object(
github,
'_fetch_latest_release_tag',
return_value='not-a-version',
),
):
assert github.is_up_to_date() is True
def test_is_up_to_date_malformed_remote_tag_no_cache_returns_false(
github_env: None,
) -> None:
with (
mock.patch.object(
github, 'get_anthias_release', return_value='2026.5.0'
),
mock.patch.object(
github,
'_fetch_latest_release_tag',
return_value='not-a-version',
),
):
assert github.is_up_to_date() is False
# ---------------------------------------------------------------------------
# handle_github_error
# ---------------------------------------------------------------------------
def test_handle_github_error_with_response(
github_env: None, redis_data: dict[str, str]
) -> None:
inner_resp = MagicMock()
inner_resp.content = b'rate limited'
exc = requests_exceptions.HTTPError(response=inner_resp)
github.handle_github_error(exc, 'test-action')
assert redis_data['github-api-error'] == 'test-action'
def test_handle_github_error_without_response(
github_env: None, redis_data: dict[str, str]
) -> None:
exc = requests_exceptions.ConnectionError()
exc.response = None
github.handle_github_error(exc, 'no-resp-action')
assert redis_data['github-api-error'] == 'no-resp-action'
def test_handle_github_error_logs_at_warning_not_error(
github_env: None,
) -> None:
"""An unreachable api.github.com is a routine condition on a
signage device (offline installs, locked-down networks) — it must
not log at ERROR, which would land in Sentry on every offline
device (ANTHIAS-8)."""
exc = requests_exceptions.ConnectionError()
exc.response = None
with (
mock.patch('anthias_server.lib.github.logging.warning') as m_warning,
mock.patch('anthias_server.lib.github.logging.error') as m_error,
):
github.handle_github_error(exc, 'latest release')
m_warning.assert_called_once()
m_error.assert_not_called()
def test_github_module_never_logs_at_error_level() -> None:
"""Every failure path in this module is degraded through
gracefully (backoff + cached verdict), so none of it belongs at
ERROR level or above — that's what the Sentry logging integration
turns into an event (ANTHIAS-8). Pin that with an AST walk over
the module's direct ``logging.error/exception/critical/fatal``
calls,
so comments and docstrings can't false-positive the check. (The
module logs via the root ``logging`` module only; a named-logger
refactor would need this test updated.)
"""
import ast
import inspect
tree = ast.parse(inspect.getsource(github))
error_level_calls = [
node.lineno
for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr in ('error', 'exception', 'critical', 'fatal')
and isinstance(node.func.value, ast.Name)
and node.func.value.id == 'logging'
]
assert not error_level_calls, (
f'ERROR-level logging calls found in lib/github.py at lines '
f'{error_level_calls} — these become Sentry events on every '
f'offline device'
)
def test_handle_github_error_surfaces_exception_detail(
github_env: None,
) -> None:
"""The no-response branch must log the exception detail, not a
bare 'no data' — Sentry previously showed 'ConnectionError
fetching latest release from GitHub: no data', hiding the
host/timeout info that str(exc) carries."""
exc = requests_exceptions.ReadTimeout('read timed out')
exc.response = None
with mock.patch('anthias_server.lib.github.logging.warning') as m_warning:
github.handle_github_error(exc, 'latest release')
assert m_warning.call_args.args[3] == 'read timed out'