Files
Anthias/tests/test_telemetry.py
Viktor Petersson 133ec78ff0 refactor(packaging): adopt src/ layout with split server/viewer packages (#2817)
* refactor(packaging): adopt src/ layout with split server/viewer packages

Move all Python source under src/ following modern packaging conventions.
Server, viewer, host-agent, and shared common code now live as four
top-level packages with clear excision boundaries — anthias_viewer can
be removed wholesale when the rewrite-out-of-Python lands without
touching the server.

  src/anthias_common/         shared: errors, utils, internal_auth, device_helper
  src/anthias_server/         Django app, REST API, Celery tasks, manage.py
    lib/                      server-only: auth, backup_helper, diagnostics, github, telemetry
  src/anthias_viewer/         player runtime (was viewer/)
  src/anthias_host_agent/     systemd-driven host shim (was host_agent.py)
  tools/raspberry_pi_imager/  moved from repo root
  tests/conftest.py           moved from repo root

pyproject.toml gets [build-system], setuptools src/ discovery, and an
anthias-manage console script. Django AppConfigs keep label='anthias_app'
and label='api' so existing migration dependency tuples don't move.
BASE_DIR computed from parents[3] to keep templates/static at repo root.
mypy_path set to ["src", "stubs"] with explicit_package_bases.

Dockerfile templates set PYTHONPATH=/usr/src/app/src; bin/start_*.sh
and CI workflows use python -m anthias_server.manage / python -m
anthias_viewer instead of bare ./manage.py and python -m viewer.
Ansible host-agent unit invokes python -m anthias_host_agent.

Verified end-to-end in the docker test container:
  - 430 unit tests pass (matches baseline)
  - 7 integration tests pass, 5 skipped (matches baseline)
  - ruff, mypy clean

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

* style: ruff format the new src/ tree

The longer post-rename module paths (anthias_common.internal_auth vs
lib.internal_auth, etc.) pushed several import lines past 79 chars, so
ruff format had to wrap them. Apply that formatting and split the one
multi-import in anthias_viewer/__init__.py into per-symbol lines so the
existing # noqa: E402 sits on the `from` line where ruff expects it,
without needing a re-anchor when format wraps the parens.

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

* chore: realign sonar + gitignore comment to src/ layout

sonar-project.properties still pointed at the pre-refactor top-level
packages (anthias_app, anthias_django, api, lib, viewer, ...) and
their old per-file coverage.exclusions paths, which would have
produced empty Sonar runs and stale exclusions. Collapse sources to
`src` and rewrite the exclusions to the new src/anthias_*/ paths.

Also fix the stale path reference in .gitignore's comment for the
test DB (now src/anthias_server/django_project/settings.py).

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

* chore: gitignore .claude/ and untrack the lock file I just leaked

Previous commit accidentally pulled in .claude/scheduled_tasks.lock
because .claude was in .dockerignore but not .gitignore. Add the
pattern to .gitignore and drop the file from the index.

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

* chore(dockerignore): exclude pytest cache, __pycache__ dirs, and the local test DB

Three entries that were missing relative to the new src/ layout:

- .anthias-test.db (and -journal/-wal/-shm siblings) — created at the
  repo root by src/anthias_server/django_project/settings.py when a
  developer runs the host pytest suite. Without this exclude, the
  next docker build COPY . bakes the file into /usr/src/app/.
- **/__pycache__ — *.py[co] only matched the .pyc/.pyo files, leaving
  the empty cache directories to ship.
- .pytest_cache — host-side, regenerable.

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

* fix(urls): preserve 'anthias_app' URL namespace, not just the app label

Copilot caught that the import-rewrite swept up the URL namespace too:
app_name in src/anthias_server/app/urls.py changed from 'anthias_app'
to 'anthias_server.app', which leaves templates/login.html's
{% url 'anthias_app:login' %} pointing at a namespace that no longer
exists — NoReverseMatch at render time when an unauthenticated request
hits the login page.

The namespace is the same kind of stable user-facing identifier as the
AppConfig label (which we already kept as 'anthias_app'). Restore it,
and revert the two reverse() callers in lib/auth.py and app/views.py
that the rewrite changed in lockstep.

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

* fix(ci): update --confcutdir to the new tools/raspberry_pi_imager path

Copilot caught that the earlier sweep missed --confcutdir=raspberry_pi_imager
(no trailing slash) — replace_all of "raspberry_pi_imager/" only matched
path-with-slash forms. Without confcutdir, pytest walks back up looking
for conftests and discovers the repo-root tests/conftest.py, which
applies the Anthias-specific Django/Redis stubs to the rpi-imager test
run on the website-deploy workflow.

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

---------

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

198 lines
5.8 KiB
Python

import json
from collections.abc import Iterator
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from requests.exceptions import ConnectionError as RequestsConnectionError
from requests.exceptions import Timeout
from anthias_server.lib import telemetry
@pytest.fixture
def redis_data() -> dict[str, str]:
return {}
@pytest.fixture
def settings_data() -> dict[str, Any]:
return {
'analytics_opt_out': False,
'resolution': '1920x1080',
'audio_output': 'hdmi',
'use_ssl': False,
}
@pytest.fixture
def fake_redis(redis_data: dict[str, str]) -> MagicMock:
fake = MagicMock()
fake.get.side_effect = redis_data.get
fake.set.side_effect = lambda key, value: redis_data.__setitem__(
key, value
)
fake.expire.side_effect = lambda key, _ttl: None
return fake
@pytest.fixture
def telemetry_env(
fake_redis: MagicMock,
settings_data: dict[str, Any],
) -> Iterator[None]:
patches = [
patch.object(telemetry, 'r', fake_redis),
patch.object(telemetry, 'is_ci', return_value=False),
patch.object(telemetry, 'is_balena_app', return_value=False),
patch.object(telemetry, 'get_git_branch', return_value='master'),
patch.object(telemetry, 'get_git_short_hash', return_value='abc1234'),
patch.object(
telemetry, 'parse_cpu_info', return_value={'model': 'Pi 4'}
),
]
settings_patch = patch.object(telemetry, 'settings')
mock_settings = settings_patch.start()
mock_settings.__getitem__.side_effect = settings_data.__getitem__
for p in patches:
p.start()
try:
yield
finally:
settings_patch.stop()
for p in patches:
p.stop()
@patch.object(telemetry, 'requests_post')
def test_sends_event_when_no_cooldown(
mock_post: Any, telemetry_env: None
) -> None:
with patch.dict('os.environ', {'DEVICE_TYPE': 'pi4-64'}):
assert telemetry.send_telemetry() is True
mock_post.assert_called_once()
call_args = mock_post.call_args
url = call_args.args[0]
assert 'measurement_id=' in url
assert 'api_secret=' in url
payload = json.loads(call_args.kwargs['data'])
assert len(payload['client_id']) == 15
event = payload['events'][0]
assert event['name'] == 'device_active'
params = event['params']
assert params['branch'] == 'master'
assert params['commit_short'] == 'abc1234'
assert params['device_type'] == 'pi4-64'
assert params['hardware_model'] == 'Pi 4'
# NOOBS is gone.
assert 'is_noobs' not in params
assert 'NOOBS' not in params
# Asset counts always present even when DB is empty.
assert 'asset_count' in params
assert 'asset_image_count' in params
assert 'asset_video_count' in params
assert 'asset_webpage_count' in params
# Display + TLS adoption.
assert params['resolution'] == '1920x1080'
assert params['audio_output'] == 'hdmi'
assert params['tls_enabled'] is False
@patch.object(telemetry, 'requests_post')
def test_passes_timeout(mock_post: Any, telemetry_env: None) -> None:
telemetry.send_telemetry()
assert mock_post.call_args.kwargs['timeout'] == telemetry.ANALYTICS_TIMEOUT
@patch.object(telemetry, 'requests_post')
def test_skips_when_cooldown_set(
mock_post: Any,
telemetry_env: None,
redis_data: dict[str, str],
) -> None:
redis_data[telemetry.TELEMETRY_COOLDOWN_KEY] = '1'
assert telemetry.send_telemetry() is False
mock_post.assert_not_called()
@patch.object(telemetry, 'requests_post')
def test_skips_when_opted_out(
mock_post: Any,
telemetry_env: None,
settings_data: dict[str, Any],
) -> None:
settings_data['analytics_opt_out'] = True
assert telemetry.send_telemetry() is False
mock_post.assert_not_called()
@patch.object(telemetry, 'requests_post')
def test_skips_in_ci(mock_post: Any, telemetry_env: None) -> None:
with patch.object(telemetry, 'is_ci', return_value=True):
assert telemetry.send_telemetry() is False
mock_post.assert_not_called()
@patch.object(telemetry, 'requests_post', side_effect=RequestsConnectionError)
def test_swallows_connection_error_and_skips_cooldown(
_mock_post: Any,
telemetry_env: None,
redis_data: dict[str, str],
) -> None:
assert telemetry.send_telemetry() is False
# No cooldown set — next tick retries.
assert telemetry.TELEMETRY_COOLDOWN_KEY not in redis_data
@patch.object(telemetry, 'requests_post', side_effect=Timeout)
def test_swallows_timeout(
_mock_post: Any,
telemetry_env: None,
redis_data: dict[str, str],
) -> None:
assert telemetry.send_telemetry() is False
assert telemetry.TELEMETRY_COOLDOWN_KEY not in redis_data
@patch.object(telemetry, 'requests_post')
def test_sets_cooldown_after_success(
_mock_post: Any,
telemetry_env: None,
redis_data: dict[str, str],
fake_redis: MagicMock,
) -> None:
telemetry.send_telemetry()
assert telemetry.TELEMETRY_COOLDOWN_KEY in redis_data
fake_redis.expire.assert_any_call(
telemetry.TELEMETRY_COOLDOWN_KEY,
telemetry.TELEMETRY_COOLDOWN_TTL,
)
@patch.object(telemetry, 'requests_post')
def test_reuses_persisted_device_id(
mock_post: Any,
telemetry_env: None,
redis_data: dict[str, str],
) -> None:
redis_data[telemetry.DEVICE_ID_KEY] = 'persisted-id-123'
telemetry.send_telemetry()
payload = json.loads(mock_post.call_args.kwargs['data'])
assert payload['client_id'] == 'persisted-id-123'
@patch.object(telemetry, 'requests_post')
def test_generates_and_persists_new_device_id(
_mock_post: Any,
telemetry_env: None,
redis_data: dict[str, str],
) -> None:
assert telemetry.DEVICE_ID_KEY not in redis_data
telemetry.send_telemetry()
assert telemetry.DEVICE_ID_KEY in redis_data
assert (
len(redis_data[telemetry.DEVICE_ID_KEY]) == telemetry.DEVICE_ID_LENGTH
)