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.
147 lines
6.1 KiB
Python
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
|