Files
Anthias/tests/test_viewer_api.py
Viktor Petersson 3091fec349 feat(api,viewer): viewer REST shim + rename AnthiasWebview → AnthiasViewer (#2907)
* feat(api,viewer): viewer REST shim + rename AnthiasWebview → AnthiasViewer

- Add GET /api/v2/viewer/playlist returning server-evaluated active
  assets, next deadline, and ``now``; gated by internal token.
- Add GET /api/v2/viewer/settings exposing only the viewer-relevant
  settings subset (shuffle/show_splash/screen_rotation/audio_output/
  debug_logging) so the internal-auth path doesn't surface operator
  credentials.
- Rename the C++ binary AnthiasWebview → AnthiasViewer (.pro file,
  Dockerfile copies, sh.Command spawn, test runner) and the D-Bus
  service anthias.webview → anthias.viewer (atomic because both
  endpoints ship in the same image).
- Migrate runtime state paths /data/.local/share/AnthiasWebview and
  /data/.cache/AnthiasWebview to AnthiasViewer with a one-shot
  symlink so existing devices keep QtWebEngine cookies / local-
  storage across the upgrade.
- Source tree src/anthias_webview/ stays put; the directory rename
  is deferred to Phase 5 when the Python viewer package is deleted.

First step of GH #2906; sets up the contract the C++ viewer will
consume in Phase 3.

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

* fix(api,viewer): address review feedback on viewer REST shim

- ViewerPlaylistViewV2 now reloads anthias.conf on read so an
  in-flight settings PATCH doesn't shuffle off a stale cached
  value — mirrors what ViewerSettingsViewV2 already did.
- AssetSerializerV2.get_is_active accepts ``now`` via context so
  ViewerPlaylistViewV2 can render the ``is_active`` field against
  the same instant the filter used; closes the millisecond race
  where a row right on a window boundary could be returned in
  ``assets`` while its ``is_active`` re-evaluated to False.
- Simplify the windowed-deadline-cap test assertion: parse the
  ISO timestamp and compare datetimes directly instead of the
  awkward dual-format string-prefix check.

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

* fix(tests): use https in viewer API fixture URI

Silences SonarCloud python:S5332 on tests/test_viewer_api.py.
The fixture URIs are never fetched — they just satisfy the
``uri`` field on Asset.objects.create — but matching the existing
test_recheck_endpoint.py convention keeps the linter quiet without
sprinkling NOSONAR comments through test data.

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

* fix(viewer): drop QtWebEngine state symlink-migration on rename

Validated on real hardware: a fresh AnthiasViewer cache rebuilds
itself on the next page load, so the bookkeeping to preserve
cookies / local-storage across the AnthiasWebview → AnthiasViewer
rename isn't worth the code. Upgraded devices just get fresh state
dirs alongside the (now-orphaned) old AnthiasWebview tree.

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-19 13:31:01 +02:00

313 lines
11 KiB
Python

"""
Tests for the viewer REST shim: GET /api/v2/viewer/playlist and
GET /api/v2/viewer/settings.
Both endpoints exist so the C++ viewer (GH #2906 Phase 3) can ask
the server for "what should I play right now and when do I need to
re-ask" without touching the Django ORM or reading the SQLite file
directly. They use the shared internal token derived from
anthias.conf — the same gating as POST /api/v2/assets/<id>/recheck.
"""
from collections.abc import Iterator
from datetime import datetime, time, timedelta
from typing import Any
import pytest
import time_machine
from django.test import Client
from django.utils import timezone
from anthias_common.internal_auth import (
INTERNAL_AUTH_HEADER,
internal_auth_token,
)
from anthias_server.app.models import Asset
from anthias_server.settings import settings as anthias_settings
_DEFAULT_PLAY_DAYS = '[1, 2, 3, 4, 5, 6, 7]'
# Mirrors anthias_server.api.views.v2._VIEWER_WINDOWED_DEADLINE_CAP_S
# — duplicated here rather than imported because importing a private
# constant for a single assertion isn't worth the coupling; bump
# both if it ever changes.
_VIEWER_WINDOWED_DEADLINE_CAP_S = 60
@pytest.fixture(autouse=True)
def _internal_auth_secret() -> Iterator[None]:
"""Set a stable secret so internal_auth_token() resolves to a
fixed value for the duration of the test, then restore."""
original_secret = anthias_settings.get('django_secret_key', '')
anthias_settings['django_secret_key'] = 'test-internal-secret'
try:
yield
finally:
anthias_settings['django_secret_key'] = original_secret
@pytest.fixture
def _restore_shuffle_setting() -> Iterator[None]:
"""Shuffle is a global setting — restore after each test that
touches it so an early test can't leave the next one shuffled."""
original = anthias_settings.get('shuffle_playlist', False)
try:
yield
finally:
anthias_settings['shuffle_playlist'] = original
def _auth_headers() -> dict[str, str]:
return {INTERNAL_AUTH_HEADER: internal_auth_token(anthias_settings)}
def _make(**kwargs: Any) -> Asset:
"""Create an Asset with sensible defaults; override per test."""
now = timezone.now()
defaults: dict[str, Any] = {
'mimetype': 'image',
'name': 'a',
'uri': 'https://example.com/x.png',
'duration': 5,
'is_enabled': True,
'nocache': False,
'is_processing': False,
'play_order': 0,
'skip_asset_check': False,
'play_days': _DEFAULT_PLAY_DAYS,
'play_time_from': None,
'play_time_to': None,
'is_reachable': True,
'last_reachability_check': None,
'metadata': {},
'start_date': now - timedelta(days=1),
'end_date': now + timedelta(days=1),
}
defaults.update(kwargs)
return Asset.objects.create(**defaults)
# ---------------------------------------------------------------------------
# /api/v2/viewer/playlist
# ---------------------------------------------------------------------------
@pytest.mark.django_db
def test_playlist_requires_internal_auth() -> None:
"""No token → 403. The endpoint must never serve an
unauthenticated request because it discloses asset URIs (which
can include credentials inline)."""
response = Client().get('/api/v2/viewer/playlist')
assert response.status_code == 403
@pytest.mark.django_db
def test_playlist_rejects_wrong_token() -> None:
"""A malformed / stale token is treated like no token at all —
same 403, no timing-leak distinction."""
response = Client().get(
'/api/v2/viewer/playlist',
headers={INTERNAL_AUTH_HEADER: 'not-the-real-token'},
)
assert response.status_code == 403
@pytest.mark.django_db
def test_playlist_empty_when_no_assets() -> None:
response = Client().get('/api/v2/viewer/playlist', headers=_auth_headers())
assert response.status_code == 200
body = response.json()
assert body['assets'] == []
assert body['deadline'] is None
assert body['now']
@pytest.mark.django_db
def test_playlist_returns_only_active_assets(
_restore_shuffle_setting: None,
) -> None:
"""Active assets are surfaced, an asset whose start_date is in
the future is filtered out (Asset.is_active() will return False
for it)."""
anthias_settings['shuffle_playlist'] = False
now = timezone.now()
_make(asset_id='active', play_order=0)
_make(
asset_id='future',
play_order=1,
start_date=now + timedelta(days=1),
end_date=now + timedelta(days=2),
)
response = Client().get('/api/v2/viewer/playlist', headers=_auth_headers())
assert response.status_code == 200
body = response.json()
asset_ids = [a['asset_id'] for a in body['assets']]
assert asset_ids == ['active']
@pytest.mark.django_db
def test_playlist_orders_by_play_order_when_not_shuffled(
_restore_shuffle_setting: None,
) -> None:
anthias_settings['shuffle_playlist'] = False
_make(asset_id='b', play_order=1)
_make(asset_id='a', play_order=0)
response = Client().get('/api/v2/viewer/playlist', headers=_auth_headers())
body = response.json()
assert [a['asset_id'] for a in body['assets']] == ['a', 'b']
@pytest.mark.django_db
def test_playlist_deadline_is_soonest_active_end_date(
_restore_shuffle_setting: None,
) -> None:
"""Two active assets: deadline is the earlier end_date because
that's when the first one drops out of the active set."""
anthias_settings['shuffle_playlist'] = False
now = timezone.now()
soonest_end = now + timedelta(hours=1)
_make(asset_id='soonest', play_order=0, end_date=soonest_end)
_make(
asset_id='later',
play_order=1,
end_date=now + timedelta(days=2),
)
response = Client().get('/api/v2/viewer/playlist', headers=_auth_headers())
body = response.json()
# ISO8601 round-trip: DRF emits microseconds + tz suffix
assert body['deadline'].startswith(soonest_end.strftime('%Y-%m-%dT%H:%M'))
@pytest.mark.django_db
def test_playlist_deadline_picks_inactive_start_date_when_scheduled(
_restore_shuffle_setting: None,
) -> None:
"""If the soonest future boundary is an *inactive* asset's
start_date (not the active one's end_date), deadline must point
at it so the viewer re-evaluates when the scheduled asset
becomes active."""
anthias_settings['shuffle_playlist'] = False
now = timezone.now()
scheduled_start = now + timedelta(hours=1)
_make(
asset_id='active',
play_order=0,
end_date=now + timedelta(days=2),
)
_make(
asset_id='scheduled',
play_order=1,
start_date=scheduled_start,
end_date=now + timedelta(days=3),
)
response = Client().get('/api/v2/viewer/playlist', headers=_auth_headers())
body = response.json()
assert body['deadline'].startswith(
scheduled_start.strftime('%Y-%m-%dT%H:%M')
)
# Only the active one is returned in the assets list.
assert [a['asset_id'] for a in body['assets']] == ['active']
@pytest.mark.django_db
def test_playlist_deadline_caps_when_asset_has_time_window(
_restore_shuffle_setting: None,
) -> None:
"""An asset with a play_time window can transition active state
without crossing a start/end boundary — the deadline must
include a 60s cap so the viewer comes back to re-check."""
anthias_settings['shuffle_playlist'] = False
# Travel to a fixed moment so play_time_from/to behaviour is
# deterministic regardless of when the test happens to run.
with time_machine.travel('2026-05-18 12:00:00+00:00', tick=False):
now = timezone.now()
_make(
asset_id='windowed',
play_order=0,
start_date=now - timedelta(days=1),
end_date=now + timedelta(days=30), # well past the 60s cap
play_time_from=time(0, 0),
play_time_to=time(23, 59),
)
response = Client().get(
'/api/v2/viewer/playlist',
headers=_auth_headers(),
)
body = response.json()
assert body['deadline'] is not None
# Time is frozen via ``time_machine.travel(..., tick=False)``, so
# the server's ``timezone.now()`` matches the test's ``now``
# exactly and the windowed cap should land at ``now + 60s`` with
# no slack to account for. Parse rather than string-compare so
# the assertion isn't sensitive to DRF's exact ISO format.
deadline = datetime.fromisoformat(body['deadline'].replace('Z', '+00:00'))
assert deadline == now + timedelta(seconds=_VIEWER_WINDOWED_DEADLINE_CAP_S)
@pytest.mark.django_db
def test_playlist_shuffles_when_setting_enabled(
_restore_shuffle_setting: None,
) -> None:
"""With shuffle on, asset membership stays the same but order
is shuffled. With many assets, repeated requests should produce
at least one non-sorted order — but to keep the test
deterministic we just verify all assets are present.
Determinism via membership rather than order: shuffle uses
SystemRandom which we don't seed, so order is genuinely
unpredictable; the property we care about is that no asset
is dropped."""
anthias_settings['shuffle_playlist'] = True
for i in range(5):
_make(asset_id=f'a{i}', play_order=i)
response = Client().get('/api/v2/viewer/playlist', headers=_auth_headers())
body = response.json()
ids = sorted(a['asset_id'] for a in body['assets'])
assert ids == [f'a{i}' for i in range(5)]
# ---------------------------------------------------------------------------
# /api/v2/viewer/settings
# ---------------------------------------------------------------------------
@pytest.mark.django_db
def test_settings_requires_internal_auth() -> None:
response = Client().get('/api/v2/viewer/settings')
assert response.status_code == 403
@pytest.mark.django_db
def test_settings_returns_viewer_subset() -> None:
response = Client().get('/api/v2/viewer/settings', headers=_auth_headers())
assert response.status_code == 200
body = response.json()
# Narrow on purpose — adding operator fields here would defeat
# the point of a viewer-scoped endpoint. Keep this strict.
assert set(body.keys()) == {
'shuffle_playlist',
'show_splash',
'screen_rotation',
'audio_output',
'debug_logging',
}
@pytest.mark.django_db
def test_settings_clamps_screen_rotation() -> None:
"""A hand-edited anthias.conf could leave an out-of-range
rotation on disk. The endpoint must clamp on read so clients
only ever see {0, 90, 180, 270}."""
original = anthias_settings.get('screen_rotation', 0)
anthias_settings['screen_rotation'] = 45
try:
response = Client().get(
'/api/v2/viewer/settings',
headers=_auth_headers(),
)
finally:
anthias_settings['screen_rotation'] = original
body = response.json()
assert body['screen_rotation'] in (0, 90, 180, 270)