Files
Anthias/tests/test_github.py
Viktor Petersson f96016ca95 refactor(tests): migrate Python test suite to pytest (#2801)
* refactor(tests): migrate Python test suite from Django runner to pytest

Replaces ./manage.py test with pytest as the single Python test runner
for speed (xdist parallelization), readability (function-style + bare
assert), and to drop the mock and unittest_parametrize extra deps in
favor of stdlib unittest.mock + pytest.mark.parametrize.

- pyproject.toml: drop mock and unittest-parametrize from dev (and
  types-mock from dev-host); add pytest, pytest-django, pytest-mock,
  pytest-xdist; add [tool.pytest.ini_options] with
  DJANGO_SETTINGS_MODULE, testpaths covering tests/, api/tests/ and
  anthias_app/, and the integration marker.
- All 13 Python test modules under tests/ and api/tests/ converted
  to function-based pytest with @pytest.mark.django_db where Django
  ORM is touched, @pytest.mark.parametrize replacing
  unittest_parametrize, and @pytest.mark.integration replacing
  Django @tag('integration'). Mocks now import unittest.mock.
- tests/test_app.py: integration tests use
  @pytest.mark.django_db(transaction=True) since the original
  unittest.TestCase did not wrap in a Django transaction (the live
  server uses the same SQLite DB).
- .github/workflows/test-runner.yml + CLAUDE.md: invocations
  switched to `pytest -n auto -m "not integration"` and
  `pytest -m integration`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(coverage): add tests for lib/auth.py

Cover NoAuth, BasicAuth, the abstract Auth base, password hashing
round-trip, the legacy SHA256 detector, and the @authorized decorator's
three branches (no auth, auth-required redirect, view passthrough).
The Authorization header path exercises malformed base64, missing
colon, unsupported scheme, and the RFC 7617 colon-in-password case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(coverage): cover GitHub + GHCR helpers in lib/github.py

remote_branch_available: happy path (200 → True), 404 → False, network
exception triggers backoff, 5xx triggers backoff, cache hits short-
circuit, and active backoff suppresses requests. Locks in #2797's
direct-branch endpoint behavior via a URL substring assertion.

fetch_remote_hash: missing GIT_BRANCH, cache hit, branch-unavailable
short-circuit, happy path, and request-exception handling.

_get_ghcr_anonymous_token: happy path, RequestException, ValueError on
JSON decode, missing token field, non-string token field.

_get_ghcr_manifest_digest: happy path, 404 (no backoff), 5xx + 429
(with backoff), RequestException, missing/empty Docker-Content-Digest
header (with backoff).

is_running_latest_published_image: missing inputs, all three cache
verdicts ('1'/'0'/'?'), backoff active, no token, no latest digest,
current digest 404 caches '?', match → True, mismatch → False, and
cache key scoped per (device_type, short_hash).

handle_github_error: with and without exc.response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(coverage): add tests for lib/diagnostics.py

Cover the env-var helpers (get_git_branch / get_git_short_hash /
get_git_hash), the file-backed helpers (get_uptime, get_debian_version,
get_load_avg, get_utc_isodate), the device-helper-backed accessors
(get_raspberry_code, get_raspberry_model), the CEC-via-subprocess
get_display_power on True/False/CEC error/Unknown/empty stdout/timeout,
and try_connectivity in all-OK / all-Error / mixed configurations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(coverage): add tests for viewer ↔ server Redis messaging

ViewerPublisher.send_to_viewer publishes the namespaced 'viewer …'
payload on VIEWER_CHANNEL; ReplySender pushes JSON onto the per-
correlation-ID list and sets the 30s TTL; ReplyCollector.recv_json
covers the BLPOP path with second-rounding, the LPOP non-blocking
path (timeout_ms <= 0), and ReplyTimeoutError on no reply. Singleton
get_instance() caches the first instance and rejects double-init.

ViewerSubscriber._consume dispatches commands with/without parameters,
skips wrong-topic and non-string payloads, falls back to the 'unknown'
handler when registered, and is a no-op when no handler matches.
run() signals viewer-subscriber-ready, falls into _consume, and on
ConnectionError marks unready before sleeping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(coverage): add pytest tests for anthias_app/views_files.py

Cover _client_ip directly (REMOTE_ADDR happy path, IPv6, malformed,
missing), the require_client_in decorator (allow/reject/malformed/
multi-CIDR), and both views (anthias_assets, static_with_mime) for
the in-CIDR happy path, out-of-CIDR rejection, traversal blocking,
missing files, directory requests, symlink escape, and the mime
override allowlist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(coverage): cover reboot/shutdown/get_display_power Celery tasks

reboot_anthias and shutdown_anthias now have both code paths exercised:
on Balena, the supervisor helper is invoked (via tenacity Retrying);
off Balena, an 'r.publish('hostcmd', …)' is issued. get_display_power
delegates to lib.diagnostics and persists the verdict + 1h TTL on
Redis. send_telemetry_task is a thin wrapper. cleanup also gains a
test for the early-return path when settings['assetdir'] is missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(coverage): expand lib/utils.py coverage and add device_helper tests

lib/utils: cover string_to_bool truthy/falsy/invalid, validate_url
across http/https/rtsp/rtmp + invalid forms, the env-var booleans
(is_ci / is_balena_app / is_demo_node / is_docker), the Balena
supervisor helpers (get_balena_device_info / reboot / shutdown /
version OK + error), JSON datetime serialisation, the perfect-paper-
password generator (length and no-symbols variant), and the
template-handle-unicode non-string fallback.

lib/device_helper: parse_cpu_info on a representative Pi 4 cpuinfo
fixture and on a minimal stub, plus get_device_type's full mapping
(pi5/Compute Module 5, pi4/Compute Module 4, pi3/Compute Module 3,
pi2, pi1) with x86 fallback when /proc/device-tree/model is missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(coverage): wire up pytest-cov and enforce 80% line+branch coverage

Add pytest-cov==6.0.0 to the dev group, configure [tool.coverage.run]
with branch coverage and the focused source list (lib, viewer, api,
anthias_app, celery_tasks, settings) plus omits for boilerplate
(migrations, asgi/wsgi/urls/routing, image_builder, host_agent,
viewer/__main__, etc.), and set fail_under = 80 in [tool.coverage.report]
so CI fails when coverage regresses.

The test-runner workflow now passes --cov flags to both the unit and
integration jobs (--cov-append on the second so the totals merge), and
points Codecov at the resulting coverage.xml that lands on the runner
workspace via the /usr/src/app bind-mount. CLAUDE.md gets a coverage
example next to the existing pytest invocation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): satisfy mypy on coverage tests

Mypy under strict settings flagged 47 errors in the recently-added
coverage tests. They fall into a few mechanical buckets, all in test
files only — no source changes:

- Replace `patch.object(<module>, '<attr>', ...)` with the string
  form `patch('<dotted.path>', ...)` for `utils.os`, `utils.requests`,
  `diagnostics.device_helper`, `diagnostics.utils`, and
  `celery_tasks_module.diagnostics`. mypy refuses to introspect the
  re-exported attribute even though it exists at runtime.
- Switch the `request.session = {}` ignore code from `[attr-defined]`
  (which mypy reports as unused) to `[assignment]` — the actual error
  is "dict cannot be assigned to SessionBase".
- Drop unused ignore comments that mypy flagged
  (`tests/test_messaging.py` `_redis = fake_redis`,
  `tests/test_github.py` `exc.response = None`,
  `tests/test_auth.py` `view()` / `view() -> str`).
- Stop assigning the result of `NoAuth.authenticate()` (declared
  `-> None`); call it as a statement instead.

Verified locally with `uv run mypy .` (Success: no issues found in 105
source files), `uv run ruff check .`, and `uv run ruff format --check .`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(sonar): add sonar-project.properties to silence test-file false positives

Sonar's default analysis treated everything under the repo as
sonar.sources, including the new coverage tests. That tripped a
batch of S5332 / S2068 / S1244 / S5443 false positives on fixtures
that intentionally exercise insecure protocols, hard-coded test
credentials, exact float comparisons, and tempfile.TemporaryDirectory
patterns.

Pin sonar.sources to the actual source dirs and mark tests/ +
api/tests/ as sonar.tests so the narrower rule set applies. Wire up
sonar.python.coverage.reportPaths so the coverage.xml emitted by
pytest-cov is consumed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(tests): make unit suite locally runnable + force-mock externals

The unit suite previously assumed Docker: settings.py hard-coded the
SQLite test DB to /data/.anthias/test.db, viewer modules required
PyGObject from python3-gi, and connect_to_redis() pointed at the
hostname `redis`. Developers had no way to run pytest from the host.

Changes
- anthias_django/settings.py: when ENVIRONMENT=test, default the
  SQLite path to BASE_DIR/.anthias-test.db. CI containers preserve the
  /data location via the new ANTHIAS_TEST_DB_PATH env var.
- docker-compose.test.yml: set ANTHIAS_TEST_DB_PATH=/data/.anthias/test.db
  so CI keeps its historic DB layout without churn.
- .gitignore: cover .anthias-test.db and its WAL siblings.
- conftest.py (new, repo root): force ENVIRONMENT=test, stub gi /
  gi.repository / pydbus in sys.modules so viewer/__init__.py imports
  without the distro PyGObject stack, replace lib.utils.connect_to_redis
  with a dict-backed MagicMock factory at conftest load time AND via an
  autouse fixture (so module-level `r = connect_to_redis()` bindings in
  celery_tasks / lib.github / lib.telemetry / api.views.* never hit a
  real socket), and ensure settings['assetdir'] exists once per session
  so legacy cleanup fixtures don't FileNotFoundError out.
- CLAUDE.md: document the local pytest recipe alongside the Docker one.

External-call audit
- requests.get/head/post/put/delete: all unit-test references inside
  patch() / patch.object() — clean.
- connect_to_redis() / module-level `r`: every reference is either
  patched per-test or mocked by the new conftest fixture.
- subprocess: only tests/test_migrate_legacy_paths.py runs a real
  subprocess, which is the migration shell script under test (local,
  no network) — left as-is per the task spec.
- DBus / gi: stubbed at conftest level; no test exercises a live bus.

Local run: `uv sync --group test && uv run pytest -m "not integration"`
yields 359 passed, 12 deselected (libcec-dev required on host for the
cec wheel build).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(tests): apply ruff format to conftest.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): address Copilot review + drop unused type:ignore in conftest

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(tests): drop type:ignore / noqa suppressions in tests + conftest

Replace bare-dict assignments to ``request.session`` in test_auth.py with
``SessionStore()`` from the signed_cookies backend (typed correctly,
no DB write). Use ``cast()`` for the singleton-sentinel assignments in
test_messaging.py instead of ``# type: ignore[assignment]``.

In conftest.py, swap the ``try: import gi`` probe for
``importlib.util.find_spec``, and use ``setattr()`` for the dynamic
attribute writes onto stubbed modules — both stop tripping mypy
without suppression. Always stub ``pydbus`` when ``gi`` is missing
(real pydbus does ``from gi.repository import GLib, GObject`` and our
minimal stub doesn't fake ``GObject``).

Drop the dead ``# noqa: E501`` markers from three test signatures (E501
isn't in the active ruff rule selection, so they were suppressing
nothing) and rename the one signature that genuinely was over 79 chars
(``test__if_git_branch_env_does_not_exist__is_up_to_date_should_return_true``
→ ``test_returns_true_when_git_branch_env_missing``).

Verified: ``ruff check``, ``ruff format --check``, ``mypy``, and the
non-integration ``pytest`` run (359 passed) are all clean with zero
suppressions remaining in tests/ + conftest.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(sonar): silence test-only rule false positives

S2068 (hard-coded credential), S1244 (float equality), S5864
(identity check), and S7492 (comprehension unpack) flag patterns
that are intentional in test fixtures. Suppress them under
tests/** and api/tests/** via sonar.issue.ignore.multicriteria
rather than scattering NOSONAR comments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(sonar): fix issue-ignore globs to actually match top-level test files

`tests/**/*.py` is Ant-style "tests/<dir>/.../<file>.py" — it
requires at least one intermediate directory and so does not
match `tests/test_auth.py`. Switch all four exclusions to
`**/test_*.py`, which matches every test file under the project
regardless of nesting and is the only pattern Sonar's
PathMatcher reliably honors for top-level test directories.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): unblock integration DB sharing, settings xdist race, sonar

Three independent issues surfaced when CI ran fcb8768d.

1. Selenium integration tests in tests/test_app.py were asserting on
   `Asset.objects.all()` and seeing 0 because pytest-django's default
   in-memory SQLite test DB is invisible to the separate
   anthias-server container handling the upload. Pin
   DATABASES['default']['TEST']['NAME'] to the same path the server
   reads via ANTHIAS_TEST_DB_PATH so both ends share one file.
   Gated on ENVIRONMENT=test because the conftest.py setdefault races
   with pytest-django's plugin-time settings load — leaving TEST.NAME
   pointed at /data/.anthias/anthias.db on local unit runs would make
   pytest-django try to open the production path.

2. tests/test_settings.py had four tests racing on a single
   `/tmp/.anthias/anthias.conf`; under `pytest -n auto` workers
   stomped on each other's writes/cleanups. Scope the temp HOME by
   PYTEST_XDIST_WORKER so each worker gets its own subtree, and
   replace mkdir+rmtree with idempotent makedirs / ignore_errors.

3. SonarCloud Automatic Analysis ignores
   sonar.issue.ignore.multicriteria from sonar-project.properties.
   Drop the dead config and silence the four genuine test-file false
   positives inline:
     - 7× S2068 (auth fixtures need literal passwords)        → # NOSONAR
     - 1× S1244 (float equality on parsed value)              → pytest.approx
     - 1× S5864 (`is sentinel_response`)                      → `==`
     - 1× S7492 (`all([... for ...])` style hint)             → drop brackets

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): per-line NOSONAR after ruff split dicts onto multiple lines

The previous commit placed `# NOSONAR` on the dict closing-brace line,
but `ruff format` reflowed the two longer dicts across multiple lines
— leaving each `'password': 'newpw'` entry on its own line without a
NOSONAR. Sonar's marker is line-scoped, so S2068 still fired on those
4 lines. Move the markers to each password key line directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(utils): mock url_fails() network calls in url_* tests

The three test_url_* tests called url_fails() with literal URLs that
hit example.com over real HTTP HEAD/GET, making the suite dependent
on outbound DNS+internet from CI runners and prone to flake under
xdist parallelism. Replace them with patched-requests equivalents
that exercise the same three branches:

  - test_url_fails_returns_true_on_connection_error
  - test_url_fails_returns_false_on_2xx_response
  - test_url_fails_short_circuits_for_invalid_url

The third test also asserts that requests.head is never called,
locking in the validate_url() short-circuit so a future regression
that lets a schemeless path slip through can't pass silently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): address Copilot's three remaining review threads

1. anthias_django/settings.py — settings.py was loaded by
   pytest-django's plugin init before the root conftest.py could run
   `os.environ.setdefault('ENVIRONMENT', 'test')`, so on local pytest
   runs `db_path` resolved to `/data/.anthias/anthias.db`. Functionally
   harmless (pytest-django shadows NAME with `:memory:` for SQLite
   when TEST.NAME is unset) but fragile and confusing. Detect pytest
   directly via `any('pytest' in a for a in sys.argv)` so the test
   branch fires regardless of import order. Covers `pytest`,
   `python -m pytest`, and `uv run pytest`.

2. conftest.py — _install_dbus_stubs() bailed out as soon as `gi`
   was importable, but a host can have system `gi` without
   pip-installed `pydbus` (or vice versa). The real pydbus's
   module-load also imports GLib and GObject from gi.repository,
   which our minimal gi stub didn't include. Restructure: if `gi`
   is missing, stub gi and pydbus together (real pydbus can't load
   against our stub gi); if `gi` is present but `pydbus` is missing,
   stub only pydbus; add GObject to the stub.

3. anthias_app/tests.py — legacy unittest.TestCase view-file tests
   are wholly subsumed by tests/test_views_files.py (which adds
   directory-request and disallowed-mime cases too). Delete the
   legacy file and drop `anthias_app` from `testpaths`. `python_files`
   tightens from `["test_*.py", "tests.py"]` to `["test_*.py"]`
   since no `tests.py` files remain.

Local: 347 pass under `pytest -n auto -m "not integration"` (was 359;
the 12-test drop matches the 12 deleted duplicates), mypy/ruff clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): gate TEST.NAME on ANTHIAS_INTEGRATION_TEST=1

Setting `DATABASES.default.TEST.NAME` to a single file path on every
pytest run forced all `pytest -n auto` xdist workers onto one SQLite
file, courting `database is locked` failures and cross-worker leakage.
The unit step doesn't actually need a shared DB — pytest-django gives
each worker its own `:memory:` SQLite when TEST.NAME is unset, which
is exactly what unit tests want.

Only the integration step needs the shared file (so the separate
anthias-server container's writes are visible to the test process'
`Asset.objects.all()` queries). Gate TEST.NAME on a new
ANTHIAS_INTEGRATION_TEST=1 env var, set in test-runner.yml only for
the `pytest -m integration` step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): address Copilot review of retry coverage + fake-redis list ops

1. .github/workflows/test-runner.yml — the integration step runs
   under nick-fields/retry. With `--cov-append`, partial line-hit
   data from any failed earlier attempt was silently merged into the
   final coverage.xml on retry success. Snapshot `.coverage` to
   `.coverage.unit-snapshot` immediately after the unit step, then
   restore from that snapshot at the start of every retry attempt
   so only the last (successful) attempt contributes integration data.

2. conftest.py — the conftest fake-Redis got list ops wrong:
   `lpop(key)` returned the entire stored list and `blpop(...)`
   always returned None. No current test exercises BLPOP against the
   shared fake (test_messaging uses its own MagicMock), but the
   semantics are a latent footgun for any test that drives
   `ReplyCollector.recv_json`. Implement real list-head pop and a
   non-blocking BLPOP that yields the first available value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(claude): note ANTHIAS_INTEGRATION_TEST=1 for local integration runs

The docker-compose integration command in CLAUDE.md still showed the
pre-`3844fbcf` invocation. Without the env var, pytest-django uses
per-worker `:memory:` SQLite, the anthias-server container writes to
a different file, and Selenium tests that assert on `Asset.objects`
silently see zero rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): skip pytest-django's DB destroy/create on integration runs

The integration step pins TEST.NAME to /data/.anthias/test.db so the
test process and the uvicorn server (started by
prepare_test_environment.sh -s) share one SQLite file. pytest-django's
default DB setup runs `os.remove(NAME)` followed by re-create + migrate.
That works today only because Django's default CONN_MAX_AGE=0 makes
uvicorn open a fresh connection per request — change CONN_MAX_AGE or
pre-warm a connection at startup and the server's handle would point
at an unlinked inode while pytest writes to the new file.

Pass --reuse-db so pytest-django opens the existing file in place and
never deletes it. The DB is already migrated by prepare_test_environment.sh
before the server starts, and transaction=True on integration tests
still flushes tables between tests for per-test isolation.

Update CLAUDE.md's local-Docker recipe to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 08:01:52 +01:00

498 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 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
# ---------------------------------------------------------------------------
# remote_branch_available
# ---------------------------------------------------------------------------
def test_remote_branch_available_no_branch(github_env: None) -> None:
assert github.remote_branch_available(None) is None
assert github.remote_branch_available('') is None
def test_remote_branch_available_happy_path(
github_env: None, redis_data: dict[str, str]
) -> None:
with mock.patch.object(
github, 'requests_get', return_value=_resp(200)
) as mock_get:
result = github.remote_branch_available('master')
assert result is True
mock_get.assert_called_once()
url = mock_get.call_args.args[0]
# New behavior (#2797): direct branch endpoint, not /branches list.
assert 'branches/master' in url
assert redis_data['remote-branch-available'] == '1'
def test_remote_branch_available_404_returns_false(
github_env: None, redis_data: dict[str, str]
) -> None:
with mock.patch.object(github, 'requests_get', return_value=_resp(404)):
assert github.remote_branch_available('nope') is False
assert redis_data['remote-branch-available'] == '0'
def test_remote_branch_available_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.remote_branch_available('master') is None
# Backoff key was set.
assert 'github-api-error' in redis_data
def test_remote_branch_available_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.remote_branch_available('master') is None
assert 'github-api-error' in redis_data
def test_remote_branch_available_uses_cached_hit(
github_env: None, redis_data: dict[str, str]
) -> None:
redis_data['remote-branch-available'] = '1'
with mock.patch.object(github, 'requests_get') as mock_get:
assert github.remote_branch_available('master') is True
mock_get.assert_not_called()
redis_data['remote-branch-available'] = '0'
with mock.patch.object(github, 'requests_get') as mock_get:
assert github.remote_branch_available('master') is False
mock_get.assert_not_called()
def test_remote_branch_available_skips_when_backoff_active(
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.remote_branch_available('master') is None
mock_get.assert_not_called()
# ---------------------------------------------------------------------------
# fetch_remote_hash
# ---------------------------------------------------------------------------
def test_fetch_remote_hash_no_branch_env(
github_env: None, monkeypatch: Any
) -> None:
monkeypatch.delenv('GIT_BRANCH', raising=False)
assert github.fetch_remote_hash() == (None, False)
def test_fetch_remote_hash_cache_hit(
github_env: None, redis_data: dict[str, str], monkeypatch: Any
) -> None:
monkeypatch.setenv('GIT_BRANCH', 'master')
redis_data['latest-remote-hash'] = 'abc123'
with mock.patch.object(github, 'requests_get') as mock_get:
result = github.fetch_remote_hash()
assert result == ('abc123', False)
mock_get.assert_not_called()
def test_fetch_remote_hash_short_circuits_when_branch_unavailable(
github_env: None, monkeypatch: Any
) -> None:
monkeypatch.setenv('GIT_BRANCH', 'master')
with mock.patch.object(
github, 'remote_branch_available', return_value=False
):
result = github.fetch_remote_hash()
assert result == (None, False)
def test_fetch_remote_hash_happy_path(
github_env: None,
redis_data: dict[str, str],
monkeypatch: Any,
) -> None:
monkeypatch.setenv('GIT_BRANCH', 'master')
resp = _resp(200, json_data={'object': {'sha': 'abc123'}})
with (
mock.patch.object(
github, 'remote_branch_available', return_value=True
),
mock.patch.object(github, 'requests_get', return_value=resp),
):
result = github.fetch_remote_hash()
assert result == ('abc123', True)
assert redis_data['latest-remote-hash'] == 'abc123'
def test_fetch_remote_hash_request_exception(
github_env: None, redis_data: dict[str, str], monkeypatch: Any
) -> None:
monkeypatch.setenv('GIT_BRANCH', 'master')
with (
mock.patch.object(
github, 'remote_branch_available', return_value=True
),
mock.patch.object(
github,
'requests_get',
side_effect=requests_exceptions.ConnectionError(),
),
):
result = github.fetch_remote_hash()
assert result == (None, False)
assert 'github-api-error' in redis_data
# ---------------------------------------------------------------------------
# _get_ghcr_anonymous_token
# ---------------------------------------------------------------------------
def test_get_ghcr_anonymous_token_happy_path(github_env: None) -> None:
resp = _resp(200, json_data={'token': 'tok-123'})
with mock.patch.object(github, 'requests_get', return_value=resp):
assert github._get_ghcr_anonymous_token() == 'tok-123'
def test_get_ghcr_anonymous_token_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._get_ghcr_anonymous_token() is None
assert redis_data['ghcr-api-error'] == '1'
def test_get_ghcr_anonymous_token_value_error_on_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._get_ghcr_anonymous_token() is None
assert redis_data['ghcr-api-error'] == '1'
def test_get_ghcr_anonymous_token_missing_field(
github_env: None, redis_data: dict[str, str]
) -> None:
resp = _resp(200, json_data={'not_token': 'abc'})
with mock.patch.object(github, 'requests_get', return_value=resp):
assert github._get_ghcr_anonymous_token() is None
assert redis_data['ghcr-api-error'] == '1'
def test_get_ghcr_anonymous_token_non_string_field(
github_env: None, redis_data: dict[str, str]
) -> None:
resp = _resp(200, json_data={'token': 12345})
with mock.patch.object(github, 'requests_get', return_value=resp):
assert github._get_ghcr_anonymous_token() is None
assert redis_data['ghcr-api-error'] == '1'
# ---------------------------------------------------------------------------
# _get_ghcr_manifest_digest
# ---------------------------------------------------------------------------
def test_get_ghcr_manifest_digest_happy_path(github_env: None) -> None:
resp = MagicMock()
resp.status_code = 200
resp.headers = {'Docker-Content-Digest': 'sha256:abc'}
with mock.patch.object(github, 'requests_head', return_value=resp):
assert github._get_ghcr_manifest_digest('tag', 'tok') == 'sha256:abc'
def test_get_ghcr_manifest_digest_404_no_backoff(
github_env: None, redis_data: dict[str, str]
) -> None:
resp = MagicMock()
resp.status_code = 404
resp.headers = {}
with mock.patch.object(github, 'requests_head', return_value=resp):
assert github._get_ghcr_manifest_digest('tag', 'tok') is None
assert 'ghcr-api-error' not in redis_data
def test_get_ghcr_manifest_digest_5xx_with_backoff(
github_env: None, redis_data: dict[str, str]
) -> None:
resp = MagicMock()
resp.status_code = 500
resp.headers = {}
with mock.patch.object(github, 'requests_head', return_value=resp):
assert github._get_ghcr_manifest_digest('tag', 'tok') is None
assert redis_data['ghcr-api-error'] == '1'
def test_get_ghcr_manifest_digest_429_with_backoff(
github_env: None, redis_data: dict[str, str]
) -> None:
resp = MagicMock()
resp.status_code = 429
resp.headers = {}
with mock.patch.object(github, 'requests_head', return_value=resp):
assert github._get_ghcr_manifest_digest('tag', 'tok') is None
assert redis_data['ghcr-api-error'] == '1'
def test_get_ghcr_manifest_digest_request_exception(
github_env: None, redis_data: dict[str, str]
) -> None:
with mock.patch.object(
github,
'requests_head',
side_effect=requests_exceptions.ConnectionError(),
):
assert github._get_ghcr_manifest_digest('tag', 'tok') is None
assert redis_data['ghcr-api-error'] == '1'
def test_get_ghcr_manifest_digest_missing_header_with_backoff(
github_env: None, redis_data: dict[str, str]
) -> None:
resp = MagicMock()
resp.status_code = 200
resp.headers = {}
with mock.patch.object(github, 'requests_head', return_value=resp):
assert github._get_ghcr_manifest_digest('tag', 'tok') is None
assert redis_data['ghcr-api-error'] == '1'
def test_get_ghcr_manifest_digest_empty_header_with_backoff(
github_env: None, redis_data: dict[str, str]
) -> None:
resp = MagicMock()
resp.status_code = 200
resp.headers = {'Docker-Content-Digest': ''}
with mock.patch.object(github, 'requests_head', return_value=resp):
assert github._get_ghcr_manifest_digest('tag', 'tok') is None
assert redis_data['ghcr-api-error'] == '1'
# ---------------------------------------------------------------------------
# is_running_latest_published_image
# ---------------------------------------------------------------------------
def test_is_running_latest_published_image_missing_inputs(
github_env: None,
) -> None:
assert github.is_running_latest_published_image(None, 'pi4') is None
assert github.is_running_latest_published_image('abc1234', None) is None
assert github.is_running_latest_published_image('', 'pi4') is None
assert github.is_running_latest_published_image('abc1234', '') is None
def test_is_running_latest_published_image_cache_hit_match(
github_env: None, redis_data: dict[str, str]
) -> None:
redis_data['latest-published-image-match:pi4:abc1234'] = '1'
assert github.is_running_latest_published_image('abc1234', 'pi4') is True
def test_is_running_latest_published_image_cache_hit_mismatch(
github_env: None, redis_data: dict[str, str]
) -> None:
redis_data['latest-published-image-match:pi4:abc1234'] = '0'
assert github.is_running_latest_published_image('abc1234', 'pi4') is False
def test_is_running_latest_published_image_cache_hit_unknown(
github_env: None, redis_data: dict[str, str]
) -> None:
redis_data['latest-published-image-match:pi4:abc1234'] = '?'
assert github.is_running_latest_published_image('abc1234', 'pi4') is None
def test_is_running_latest_published_image_backoff_active(
github_env: None, redis_data: dict[str, str]
) -> None:
redis_data['ghcr-api-error'] = '1'
assert github.is_running_latest_published_image('abc1234', 'pi4') is None
def test_is_running_latest_published_image_no_token(github_env: None) -> None:
with mock.patch.object(
github, '_get_ghcr_anonymous_token', return_value=None
):
assert (
github.is_running_latest_published_image('abc1234', 'pi4') is None
)
def test_is_running_latest_published_image_no_latest_digest(
github_env: None,
) -> None:
with (
mock.patch.object(
github, '_get_ghcr_anonymous_token', return_value='tok'
),
mock.patch.object(
github, '_get_ghcr_manifest_digest', return_value=None
),
):
assert (
github.is_running_latest_published_image('abc1234', 'pi4') is None
)
def test_is_running_latest_published_image_current_404_caches_unknown(
github_env: None, redis_data: dict[str, str]
) -> None:
# First call returns latest digest, second returns None (404).
digests = ['sha256:latest', None]
with (
mock.patch.object(
github, '_get_ghcr_anonymous_token', return_value='tok'
),
mock.patch.object(
github,
'_get_ghcr_manifest_digest',
side_effect=digests,
),
):
assert (
github.is_running_latest_published_image('abc1234', 'pi4') is None
)
assert redis_data['latest-published-image-match:pi4:abc1234'] == '?'
def test_is_running_latest_published_image_match(
github_env: None, redis_data: dict[str, str]
) -> None:
digests = ['sha256:same', 'sha256:same']
with (
mock.patch.object(
github, '_get_ghcr_anonymous_token', return_value='tok'
),
mock.patch.object(
github,
'_get_ghcr_manifest_digest',
side_effect=digests,
),
):
assert (
github.is_running_latest_published_image('abc1234', 'pi4') is True
)
assert redis_data['latest-published-image-match:pi4:abc1234'] == '1'
def test_is_running_latest_published_image_mismatch(
github_env: None, redis_data: dict[str, str]
) -> None:
digests = ['sha256:latest', 'sha256:older']
with (
mock.patch.object(
github, '_get_ghcr_anonymous_token', return_value='tok'
),
mock.patch.object(
github,
'_get_ghcr_manifest_digest',
side_effect=digests,
),
):
assert (
github.is_running_latest_published_image('abc1234', 'pi4') is False
)
assert redis_data['latest-published-image-match:pi4:abc1234'] == '0'
def test_is_running_latest_published_image_cache_key_scoped(
github_env: None, redis_data: dict[str, str]
) -> None:
"""Cache verdict for one (board, hash) doesn't leak to another."""
redis_data['latest-published-image-match:pi4:abc1234'] = '1'
# Different hash → not a hit → fall through to lookup logic.
with mock.patch.object(
github, '_get_ghcr_anonymous_token', return_value=None
):
assert (
github.is_running_latest_published_image('def5678', 'pi4') is None
)
# ---------------------------------------------------------------------------
# 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'