Files
espresso_frame/server/tests/test_widget_config_and_queue_endpoints.py
T
Thomas Faour edbd90745b
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 51s
Add font family choice to the text widget, move toolbar below the editor
Six more vendored families alongside the existing Noto Sans (Inter,
Source Sans 3, Noto Serif, Crimson Text, Arvo, IBM Plex Mono -- sans/
serif/slab/mono variety), all OFL-licensed with their own per-family
license file in app/fonts/ since each has a different copyright holder.
Static Regular/Bold/Italic/BoldItalic builds only -- variable-font-only
families (Inter and Source Sans's current Google Fonts releases, plus
Playfair Display/Lora/Merriweather) were skipped in favor of static
builds from their own upstream repos, keeping every family's loading
code uniform with what was already there. Considered but deliberately
left out: Georgia -- a proprietary Microsoft core font, not freely
redistributable, unlike everything else vendored here.

Also moves the bold/italic/underline/color toolbar below the
contenteditable box per request, and reorders the dialog's Settings
card to a more natural family-then-size order.

Fixes a latent migration bug this surfaced: migration 20 (static image
widget) used Base.metadata.create_all, which creates every table
declared in Base.metadata that's missing, not just its own new one --
harmless when nothing else pending, but once TextWidgetConfig existed
it would silently pre-create text_widget_configs (in whatever shape
models.py currently declares) before migration 21 got a turn, so
migration 21's own CREATE TABLE (or a later ALTER TABLE adding
font_family) would collide with a table create_all had already leaked
into existence. Both migrations 20 and 21 now use raw, frozen CREATE
TABLE SQL instead, matching migration 17's existing precedent for
exactly this reason.
2026-07-25 16:05:22 +00:00

423 lines
15 KiB
Python

"""api_widgets.py's config-save (form-urlencoded, dispatched by
widget_type) and the photo-queue endpoints it shares the file with --
the moved-and-consolidated counterparts of the old frame-level
api_config_save/api_queue/etc. No prior HTTP-level coverage existed for
either the old or new shape of these endpoints; added after manual
browser testing caught api_widget_config_save expecting a JSON body
while the (unmodified, copied-over) dialog JS posts form-urlencoded data
-- a real bug an HTTP-level test would have caught immediately."""
from __future__ import annotations
import io
from PIL import Image
from app.models import (
CalendarWidgetConfig,
Frame,
PhotoWidgetConfig,
StaticWidgetConfig,
TaskWidgetConfig,
TextWidgetConfig,
Widget,
)
from .conftest import csrf_headers
_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}]
def _photo_widget(db_session) -> Widget:
return db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").one()
def _add_calendar_widget(db_session) -> Widget:
import time
widget = Widget(frame_id=1, widget_type="calendar", x=0, y=0, w=3, h=2,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
def _add_tasks_widget(db_session) -> Widget:
import time
widget = Widget(frame_id=1, widget_type="tasks", x=0, y=0, w=2, h=2,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(TaskWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
def _add_static_widget(db_session) -> Widget:
import time
widget = Widget(frame_id=1, widget_type="static", x=0, y=0, w=2, h=2,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(StaticWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
def _add_text_widget(db_session) -> Widget:
import time
widget = Widget(frame_id=1, widget_type="text", x=0, y=0, w=2, h=1,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(TextWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes:
buf = io.BytesIO()
Image.new("RGB", size, color).save(buf, format="PNG")
return buf.getvalue()
def _mock_immich(monkeypatch):
monkeypatch.setattr("app.routers.api_widgets.immich_client_for", lambda frame: object())
monkeypatch.setattr("app.routers.api_widgets.list_assets", lambda client, album_id: _ASSETS)
# --- api_widget_config_save ---
def test_config_save_updates_a_photos_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _photo_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"album_id": "album-42", "order": "shuffle", "display_mode": "stretch_fill",
"queue_target_len": "15"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.album_id == "album-42"
assert cfg.order == "shuffle"
assert cfg.display_mode == "stretch_fill"
assert cfg.queue_target_len == 15
def test_config_save_updates_a_calendar_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_calendar_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"calendar_view": "week", "calendar_week_start": "1", "calendar_week_days": "5",
"calendar_week_layout": "vertical", "calendar_weather_enabled": "true",
"calendar_weather_units": "celsius"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(CalendarWidgetConfig, widget.id)
assert cfg.view == "week"
assert cfg.week_start == 1
assert cfg.week_days == 5
assert cfg.week_layout == "vertical"
assert cfg.weather_enabled is True
assert cfg.weather_units == "celsius"
def test_config_save_updates_a_tasks_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_tasks_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"tasks_name": "Chores", "tasks_show_completed": "true"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(TaskWidgetConfig, widget.id)
assert cfg.name == "Chores"
assert cfg.show_completed is True
def test_config_save_truncates_an_overlong_tasks_name(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_tasks_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"tasks_name": "A" * 200},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(TaskWidgetConfig, widget.id)
assert len(cfg.name) == 40
def test_config_save_updates_a_static_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_static_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"display_mode": "letterbox"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(StaticWidgetConfig, widget.id)
assert cfg.display_mode == "letterbox"
def test_config_save_rejects_an_unrecognized_static_display_mode(client, db_session):
"""Falls back to the default rather than erroring -- same "truncate/
clamp, don't reject" posture as the other config-save fields."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_static_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"display_mode": "crop_faces"}, # valid for photos, not offered for static
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(StaticWidgetConfig, widget.id)
assert cfg.display_mode == "crop_fill"
def test_config_save_updates_a_text_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_text_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={
"text_html": '<div>Hello <b>world</b></div>',
"text_font_size": "40",
"text_font_family": "serif",
"text_align": "center",
"text_background_color": "#ffdb00",
},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(TextWidgetConfig, widget.id)
assert cfg.content == [[
{"text": "Hello ", "bold": False, "italic": False, "underline": False, "color": None, "bg": None},
{"text": "world", "bold": True, "italic": False, "underline": False, "color": None, "bg": None},
]]
assert cfg.font_size == 40
assert cfg.font_family == "serif"
assert cfg.align == "center"
assert cfg.background_color == "#ffdb00"
def test_config_save_clamps_text_font_size_and_rejects_bad_family_align_and_color(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_text_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"text_font_size": "500", "text_font_family": "comic-sans", "text_align": "diagonal",
"text_background_color": "not-a-color"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(TextWidgetConfig, widget.id)
assert cfg.font_size == 96 # clamped to MAX_TEXT_FONT_SIZE
assert cfg.font_family == "sans" # fell back to DEFAULT_FONT_FAMILY
assert cfg.align == "left" # fell back to the default
assert cfg.background_color == "#ffffff" # fell back to the default
def test_config_save_only_partially_updates_provided_fields(client, db_session):
"""Fields not present in the POST are left untouched -- the whole
point of the partial-update convention (each dialog's own form only
ever posts its own fields)."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _photo_widget(db_session)
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.order = "shuffle"
db_session.commit()
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"queue_target_len": "10"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
cfg = db_session.get(PhotoWidgetConfig, widget.id)
assert cfg.queue_target_len == 10
assert cfg.order == "shuffle" # untouched
def test_config_save_calendar_fields_are_a_no_op_on_a_photos_widget(client, db_session):
"""Posting calendar-shaped fields at a photos widget's config
endpoint doesn't error -- it just doesn't apply, since the dispatch
is purely by widget.widget_type."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _photo_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"calendar_view": "week"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
def test_config_save_404s_for_a_widget_id_that_does_not_exist(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/widgets/999999/config", data={"order": "shuffle"},
headers=csrf_headers(client))
assert resp.status_code == 404
# --- photo queue (moved from the old frame-level /api/frames/{id}/queue) ---
def test_queue_requires_a_configured_album(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _photo_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/queue")
assert resp.status_code == 400
def test_queue_returns_current_and_upcoming(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
frame.immich_url = "http://immich.example.com"
frame.immich_api_key = "key"
widget = _photo_widget(db_session)
cfg = db_session.get(PhotoWidgetConfig, widget.id)
cfg.album_id = "album-1"
db_session.commit()
_mock_immich(monkeypatch)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/queue")
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["current"]["id"] == "asset-1"
assert [u["id"] for u in data["upcoming"]] == ["asset-2", "asset-3"]
assert data["control"]["you"] is True
def test_queue_400s_when_widget_is_not_photos(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_calendar_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/queue")
assert resp.status_code == 400
# --- static image: upload/preview ------------------------------------------
def test_static_upload_decodes_and_stores_the_image(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_static_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/static-upload",
files={"file": ("photo.png", _png_bytes(size=(20, 10)), "image/png")},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
assert resp.json()["filename"] == "photo.png"
cfg = db_session.get(StaticWidgetConfig, widget.id)
assert cfg.image is not None
assert Image.open(io.BytesIO(cfg.image)).size == (20, 10)
assert cfg.original_filename == "photo.png"
assert cfg.uploaded_at > 0
def test_static_upload_rejects_a_non_image_non_pdf_file(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_static_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/static-upload",
files={"file": ("notes.txt", b"just some text", "text/plain")},
headers=csrf_headers(client),
)
assert resp.status_code == 400
cfg = db_session.get(StaticWidgetConfig, widget.id)
assert cfg.image is None
def test_static_upload_400s_when_widget_is_not_static(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_calendar_widget(db_session)
resp = client.post(
f"/api/frames/1/widgets/{widget.id}/static-upload",
files={"file": ("photo.png", _png_bytes(), "image/png")},
headers=csrf_headers(client),
)
assert resp.status_code == 400
def test_preview_static_400s_before_anything_is_uploaded(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_static_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/static")
assert resp.status_code == 400
def test_preview_static_renders_after_upload(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_static_widget(db_session)
client.post(
f"/api/frames/1/widgets/{widget.id}/static-upload",
files={"file": ("photo.png", _png_bytes(), "image/png")},
headers=csrf_headers(client),
)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/static")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
# --- text: preview -----------------------------------------------------
def test_preview_text_400s_before_anything_is_authored(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_text_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
assert resp.status_code == 400
def test_preview_text_renders_after_saving_content(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_text_widget(db_session)
client.post(
f"/api/frames/1/widgets/{widget.id}/config",
data={"text_html": "<div>Hello world</div>"},
headers=csrf_headers(client),
)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"] == "image/png"
def test_preview_text_400s_for_a_widget_that_is_not_text(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget = _add_static_widget(db_session)
resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/text")
assert resp.status_code == 400