refactor(tests): migrate Python test suite to pytest - #2801
Conversation
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]>
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]>
There was a problem hiding this comment.
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/andapi/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.
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]>
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]>
There was a problem hiding this comment.
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.
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]>
There was a problem hiding this comment.
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.
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]>
There was a problem hiding this comment.
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.
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]>
There was a problem hiding this comment.
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.
…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]>
There was a problem hiding this comment.
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.
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]>
There was a problem hiding this comment.
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.
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]>
|
There was a problem hiding this comment.
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.
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]>
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]>
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.
* 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]>
…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]>



Big-bang migration from Django's
manage.py testrunner 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— addspytest,pytest-django,pytest-mock,pytest-xdist,pytest-cov; dropsmock,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 withfail_under = 80line+branch gate..github/workflows/test-runner.yml:./manage.py test --noinput --parallel --exclude-tag=integration→pytest -n auto -m "not integration" --cov ..../manage.py test --noinput --tag=integration→pytest -m integration --reuse-db --cov-append ...(env:ANTHIAS_INTEGRATION_TEST=1).coverageafter 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:ENVIRONMENT=test(andanthias_django/settings.pyalso detects pytest viasys.argvas a fallback for plugin-time settings load).gi/gi.repository/pydbusbased on what's actually missing on the host.lib.utils.connect_to_rediswith a dict-backed fake that implements list semantics forRPUSH/LPOP/BLPOP.tests/andapi/tests/converted in place.anthias_app/tests.py(legacy unittest.TestCase view-file tests) deleted —tests/test_views_files.pysubsumes 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-dbflag).Conversion patterns
unittest.TestCase/django.test.TestCase(where the class was just a container)setUp/tearDown@pytest.fixturewithyieldself.assertEqual / assertIn / assertTrue / …assert@unittest.skip(...)@pytest.mark.skip(reason=...)@tag('integration')fromdjango.test@pytest.mark.integrationunittest_parametrize.@parametrize@pytest.mark.parametrizeimport mockfrom unittest import mock(stdlib)subTest(...)loops@pytest.mark.parametrize@pytest.mark.django_db(or(transaction=True)for Selenium integration tests intest_app.py)@mock.patchdecorators kept as-is where they were already clean.pytest-mock'smockerfixture is available but the migration didn't force a rewrite of every patch.Things to watch / non-obvious choices
tests/test_app.pySelenium integration tests are marked@pytest.mark.django_db(transaction=True). They share the SQLite file at/data/.anthias/test.dbwith the uvicorn server started byprepare_test_environment.sh -s. The shared-file behaviour is gated onANTHIAS_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 underpytest -n auto.--reuse-dbskips pytest-django's destroy+create cycle so uvicorn's open SQLite handle stays valid across runs.api/tests/test_common.pydoesreverse('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.tests/test_settings.pyscopes its/tmp/.anthias-…config root byPYTEST_XDIST_WORKERso the four config-rewriting tests don't race underpytest -n auto.[('v1',), ('v1_1',), …]) collapsed to['v1', 'v1_1', …]— pytest's parametrize takes scalars when there's only one parameter.# NOSONAR(auth fixtures need literal passwords;sonar.issue.ignore.multicriteriadoesn't work in SonarCloud Automatic Analysis).Test plan
uv run ruff check .cleanuv run ruff format --check .cleanuv run mypy .cleanuv run pytest -n auto -m "not integration"in ~6s🤖 Generated with Claude Code