Files
espresso_frame/server/tests/test_migrations.py
T
tfaour 8bc0749b42
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m5s
Widget system Phase 0: data model + migration
First step of replacing Frame.mode (one renderer owns the whole panel)
with an Android-home-screen-style widget system -- a frame will hold N
independently placed/sized widgets (photos/calendar/whiteboard), each
with its own config/state, plus fully user-assignable NEXT/BACK button
actions. Full plan at .claude/plans/prancy-snacking-iverson.md.

This phase is additive only and changes no existing behavior -- nothing
reads these new tables yet:

- models.py: Widget (placement) + PhotoWidgetConfig/CalendarWidgetConfig/
  WhiteboardWidgetConfig (per-type 1:1 extension tables, matching this
  codebase's existing convention of dedicated tables for naturally-scoped
  state rather than one wide table) + FrameButtonAction (ordered
  (widget, action) bindings per physical button).
- grid.py: pure snap-to-grid placement math, defined relative to the
  panel's long/short axis so it stays valid across
  logical_render_size(orientation)'s genuine width/height swap for
  portrait, not just a rotation applied at the end.
- db.py: widget_locked(), the widget-scoped equivalent of frame_locked()
  -- deliberately still locks at frame granularity (not a new per-widget
  lock) to avoid a new class of multi-lock deadlock bugs.
- migration.py: _migration_16 creates the new tables; a separate
  _ensure_widgets_backfilled() (ORM-based, not raw SQL -- much less
  error-prone for this much per-mode branching) gives every existing
  frame a widget reproducing its exact current mode/settings, so
  upgrading changes nothing about what a frame displays or what its
  buttons do. calendar_photo_inlay frames specifically get two widgets
  (calendar + photo, split like the old inlay did) rather than silently
  losing the photo half.

10 new tests covering fresh-install backfill, re-run idempotency, the
photo-inlay two-widget case, whiteboard's check_now button mapping, and
migration_16's actual CREATE TABLE path against a simulated pre-existing
database (not just the fresh-install create_all() shortcut). Full suite
(69 tests) passes.
2026-07-24 08:27:47 -04:00

201 lines
7.9 KiB
Python

"""migration.py structural sanity: MIGRATIONS is well-formed, a fresh
install lands on the latest schema version with frame #1 + server
settings seeded, and re-running run_migrations() is a true no-op (it's
called unconditionally at every app.main import -- see main.py -- so it
has to tolerate being invoked against an already-current database)."""
from __future__ import annotations
import time
from sqlalchemy import inspect, select, text
from app import db as db_module
from app import grid
from app.migration import MIGRATIONS, run_migrations
from app.models import (
CalendarWidgetConfig,
Frame,
FrameButtonAction,
PhotoWidgetConfig,
ServerSettings,
Widget,
WhiteboardWidgetConfig,
)
def test_migrations_list_is_sequential_and_unique():
versions = [v for v, _ in MIGRATIONS]
assert versions == sorted(versions)
assert len(versions) == len(set(versions))
assert versions == list(range(1, len(versions) + 1))
def test_fresh_install_lands_on_latest_version(db_session):
with db_module.engine.connect() as conn:
row = conn.execute(text("SELECT version FROM schema_version")).fetchone()
assert row is not None
assert row[0] == MIGRATIONS[-1][0]
def test_fresh_install_seeds_frame_one_and_server_settings(db_session):
frame = db_session.get(Frame, 1)
assert frame is not None
assert frame.name
settings = db_session.get(ServerSettings, 1)
assert settings is not None
def test_rerunning_migrations_is_a_no_op(db_session):
frame_count_before = len(db_session.query(Frame).all())
run_migrations()
run_migrations()
frame_count_after = len(db_session.query(Frame).all())
assert frame_count_before == frame_count_after == 1
def test_expected_columns_exist_on_current_schema():
"""A light spot-check, not exhaustive -- one column from a handful of
the more recent migrations, to catch an ALTER that silently didn't
apply (e.g. a typo'd table/column name in a migration function)."""
inspector = inspect(db_module.engine)
user_columns = {c["name"] for c in inspector.get_columns("users")}
frame_columns = {c["name"] for c in inspector.get_columns("frames")}
assert "webdav_base_url" in user_columns # migration 15
assert "webdav_username" in user_columns # migration 14
assert "calendar_caldav_url" in user_columns
assert "whiteboard_cached_image" in frame_columns # migration 14
assert "calendar_week_start_offset" in frame_columns
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
def test_fresh_install_backfills_one_photos_widget_with_default_buttons(db_session):
frame = db_session.get(Frame, 1)
assert frame.mode == "photos"
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
assert len(widgets) == 1
widget = widgets[0]
assert widget.widget_type == "photos"
assert (widget.x, widget.y, widget.w, widget.h) == grid.full_panel_rect(frame.orientation)
config = db_session.get(PhotoWidgetConfig, widget.id)
assert config is not None
assert config.album_id == frame.album_id
actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all()
assert {a.button: a.action for a in actions} == {"next": "advance", "back": "back"}
assert all(a.widget_id == widget.id for a in actions)
def test_rerunning_migrations_does_not_duplicate_widgets(db_session):
run_migrations()
run_migrations()
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == 1)).all()
assert len(widgets) == 1
def test_calendar_photo_inlay_frame_backfills_into_two_widgets(db_session):
"""Reproduces the old fixed 50/50 inlay split as two independent,
non-overlapping widgets instead of silently dropping the photo half
on upgrade -- see models.py's CalendarWidgetConfig docstring."""
frame = Frame(
name="Inlay Frame", device_token="tok-inlay", manage_token="mtok-inlay",
mode="calendar", orientation="landscape", calendar_view="week",
calendar_photo_inlay=True, album_id="album-123",
current_asset_id="asset-1", queue=["asset-1", "asset-2"],
created_at=time.time(),
)
db_session.add(frame)
db_session.commit()
run_migrations()
widgets = db_session.scalars(
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
).all()
assert len(widgets) == 2
cal_widget, photo_widget = widgets
assert cal_widget.widget_type == "calendar"
assert photo_widget.widget_type == "photos"
cal_rect = (cal_widget.x, cal_widget.y, cal_widget.w, cal_widget.h)
photo_rect = (photo_widget.x, photo_widget.y, photo_widget.w, photo_widget.h)
assert not grid.overlaps(cal_rect, photo_rect)
assert cal_widget.w + photo_widget.w == grid.grid_dims("landscape")[0]
assert cal_widget.h == photo_widget.h == grid.grid_dims("landscape")[1]
cal_config = db_session.get(CalendarWidgetConfig, cal_widget.id)
assert cal_config.view == "week"
photo_config = db_session.get(PhotoWidgetConfig, photo_widget.id)
assert photo_config.album_id == "album-123"
assert photo_config.current_asset_id == "asset-1"
assert photo_config.queue == ["asset-1", "asset-2"]
actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all()
assert {a.button: a.action for a in actions} == {"next": "advance", "back": "back"}
assert all(a.widget_id == cal_widget.id for a in actions)
def test_whiteboard_frame_backfills_check_now_on_both_buttons(db_session):
frame = Frame(
name="WB Frame", device_token="tok-wb", manage_token="mtok-wb",
mode="whiteboard", orientation="portrait",
whiteboard_url="https://example.com/board.whiteboard",
created_at=time.time(),
)
db_session.add(frame)
db_session.commit()
run_migrations()
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
assert len(widgets) == 1
widget = widgets[0]
assert widget.widget_type == "whiteboard"
assert (widget.x, widget.y, widget.w, widget.h) == grid.full_panel_rect("portrait")
config = db_session.get(WhiteboardWidgetConfig, widget.id)
assert config.url == "https://example.com/board.whiteboard"
actions = db_session.scalars(select(FrameButtonAction).where(FrameButtonAction.frame_id == frame.id)).all()
assert {a.action for a in actions} == {"check_now"}
def test_migration_16_raw_sql_path_applies_to_an_existing_pre_widget_database(db_session):
"""Exercises _migration_16's actual CREATE TABLE statements (the real
"existing production database upgrading past this migration"
scenario) rather than the fresh-install create_all() shortcut, which
every other test in this file goes through instead."""
with db_module.engine.begin() as conn:
for table in ("frame_button_actions", "whiteboard_widget_configs",
"calendar_widget_configs", "photo_widget_configs", "widgets"):
conn.execute(text(f"DROP TABLE {table}"))
conn.execute(text("UPDATE schema_version SET version = 15"))
frame = Frame(
name="Upgrading Frame", device_token="tok-up", manage_token="mtok-up",
mode="photos", album_id="legacy-album", current_asset_id="legacy-asset",
queue=["legacy-asset", "next-asset"], created_at=time.time(),
)
db_session.add(frame)
db_session.commit()
frame_id = frame.id
run_migrations()
with db_module.engine.begin() as conn:
version = conn.execute(text("SELECT version FROM schema_version")).scalar()
assert version == MIGRATIONS[-1][0]
widgets = db_session.scalars(select(Widget).where(Widget.frame_id == frame_id)).all()
assert len(widgets) == 1
config = db_session.get(PhotoWidgetConfig, widgets[0].id)
assert config.album_id == "legacy-album"
assert config.current_asset_id == "legacy-asset"
assert config.queue == ["legacy-asset", "next-asset"]