Files
espresso_frame/server/tests/test_device_widget_dispatch.py
T
tfaour c323402895 Fix scan-to-download auth and share every photo widget's current photo
The share QR's URL carried no auth params at all, so it silently fell
back through require_device's legacy-token resolution to whichever
frame happened to still be flagged legacy -- working only by accident
for a single frame, sharing the wrong frame's photos for any other, and
going fully dead once that frame's legacy flag was cleared.

Move the endpoint to manage.py, keyed on the frame's own manage_token
(same pattern /m/<manage_token> already uses) instead of device auth.
Since the server now resolves assets itself instead of trusting a
caller-supplied asset_id, it naturally generalizes to gather every
photo widget's current photo into one Immich share link, not just one
"primary" widget's.
2026-07-27 14:38:26 +00:00

147 lines
6.1 KiB
Python

"""End-to-end HTTP tests for the widget-system cutover in
routers/device.py -- /frame/image, /frame/advance, and /frame/back
against real widget rows (via the migration-backfilled frame #1, or a
purpose-built second frame), a real TestClient, real
render_panel/compose_into. Only Immich itself is mocked (monkeypatched
at the app.widgets.photos module boundary, same pattern as
test_widgets_photos.py) -- everything else in the pipeline is real.
This is the one place in the suite that actually exercises
routers/device.py's dispatch over HTTP; the render-size-invariant tests
exercise the renderers directly, and the widget unit tests exercise
app/widgets/*.py directly, but neither proves device.py's own wiring
(the compositor, the button-action runner, the manage-overlay hookup)
is correct -- that's what this file is for."""
from __future__ import annotations
import time
from app import widgets
from app.models import (
CalendarWidgetConfig,
Frame,
FrameButtonAction,
PhotoWidgetConfig,
Widget,
)
EXPECTED_BYTES = 800 * 480 // 2
_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}]
def _mock_immich(monkeypatch):
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
from PIL import Image
source = Image.new("RGB", (100, 80), (10, 20, 30))
monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", lambda client, mode, asset_id: (source, None))
def test_unclaimed_frame_shows_placeholder(client, db_session):
frame = db_session.get(Frame, 1)
frame.owner_user_id = None
db_session.commit()
resp = client.get("/frame/image")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
def test_claimed_frame_with_unconfigured_photo_widget_still_renders(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.get("/frame/image")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
frame = db_session.get(Frame, 1)
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.album_id == "" # never configured -- still rendered fine, as a placeholder region
def test_configured_photo_widget_renders_and_advances_via_button(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.album_id = "album-1"
db_session.commit()
_mock_immich(monkeypatch)
resp = client.get("/frame/image")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
db_session.refresh(cfg)
assert cfg.current_asset_id == "asset-1"
resp = client.post("/frame/advance")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
db_session.refresh(cfg)
assert cfg.current_asset_id != "asset-1" # the default next->advance binding fired
resp = client.post("/frame/back")
assert resp.status_code == 200
db_session.refresh(cfg)
assert cfg.current_asset_id == "asset-1" # back undid it
def test_manage_flag_still_returns_a_valid_image(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
db_session.get(PhotoWidgetConfig, widget.id).album_id = "album-1"
db_session.commit()
_mock_immich(monkeypatch)
plain = client.get("/frame/image").content
with_manage = client.get("/frame/image?manage=1").content
assert len(with_manage) == EXPECTED_BYTES
assert with_manage != plain # the manage-QR overlay actually got composited in
def test_two_widget_frame_composites_both_and_next_targets_calendar(client, db_session, monkeypatch):
"""A second frame (not frame #1) with two independent widgets -- a
calendar widget and a photo widget, addressed directly the way the
migration's calendar_photo_inlay backfill shapes a frame -- exercised
end to end over HTTP, not just via the migration's own unit tests."""
_mock_immich(monkeypatch)
frame = Frame(
name="Two Widget Frame", device_id="aabbccddeeff", device_token="devtok-2",
manage_token="mtok-2", orientation="landscape", created_at=time.time(),
)
db_session.add(frame)
db_session.flush()
cal_widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=4, h=5,
sort_order=0, created_at=time.time())
photo_widget = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
sort_order=1, created_at=time.time())
db_session.add_all([cal_widget, photo_widget])
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=cal_widget.id, view="agenda", browse_offset=0))
db_session.add(PhotoWidgetConfig(widget_id=photo_widget.id, album_id="album-1"))
db_session.add_all([
FrameButtonAction(frame_id=frame.id, button="next", widget_id=cal_widget.id, action="advance"),
FrameButtonAction(frame_id=frame.id, button="back", widget_id=cal_widget.id, action="back"),
])
db_session.commit()
resp = client.get(f"/frame/image?id={frame.device_id}&token={frame.device_token}")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
# NEXT is bound only to the calendar widget -- pressing it should
# move the calendar's browse_offset, not touch the photo widget.
resp = client.post(f"/frame/advance?id={frame.device_id}&token={frame.device_token}")
assert resp.status_code == 200
cal_cfg = db_session.get(CalendarWidgetConfig, cal_widget.id)
photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id)
assert cal_cfg.browse_offset == 1
assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it