mirror of
https://github.com/Screenly/Anthias.git
synced 2026-06-10 09:08:09 -04:00
* 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>
498 lines
16 KiB
Python
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'
|