"""GET/PUT /api/frames/{id}/buttons -- the button-assignment UI's API (see routers/api_frames.py's api_buttons_get/api_buttons_save and static/frame_config.js). Covers the CRUD/validation layer; multi-action execution order and partial-failure-continues on an actual button press are already exercised end-to-end in test_device_widget_dispatch.py and routers/device.py's _run_button_actions -- this file doesn't re-test device.py's dispatch, just that the assignment API stores/serves/ validates what the UI edits.""" from __future__ import annotations import time from app.models import Frame, FrameButtonAction, Widget, WhiteboardWidgetConfig from .conftest import csrf_headers, link_user, login, make_user def _add_whiteboard_widget(db_session, frame: Frame) -> Widget: widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5, sort_order=1, created_at=time.time()) db_session.add(widget) db_session.flush() db_session.add(WhiteboardWidgetConfig(widget_id=widget.id)) db_session.commit() return widget def test_get_buttons_reflects_the_default_migration_mapping(client, db_session): """Frame #1's auto-migrated photos widget should already have NEXT -> advance, BACK -> back from _default_button_actions (see migration.py) -- the UI just needs to be able to see that default.""" client.post("/setup", data={"username": "alice", "password": "hunter22"}) frame = db_session.get(Frame, 1) photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() resp = client.get("/api/frames/1/buttons") assert resp.status_code == 200 data = resp.json() assert {w["id"]: w["widget_type"] for w in data["widgets"]} == {photo_widget.id: "photos"} photo_actions = {a["action"] for w in data["widgets"] for a in w["actions"]} assert photo_actions == {"advance", "back"} assert data["next"] == [{"id": data["next"][0]["id"], "widget_id": photo_widget.id, "action": "advance"}] assert data["back"] == [{"id": data["back"][0]["id"], "widget_id": photo_widget.id, "action": "back"}] def test_get_buttons_includes_placement_and_grid_dims(client, db_session): """Two widgets of the same type otherwise look identical in the assignment UI ("Photos" / "Photos") -- the client tells them apart using x/y/w/h against the frame's grid dims (see static/frame_config.js's buildWidgetNames), so the API needs to actually hand those over.""" client.post("/setup", data={"username": "alice", "password": "hunter22"}) frame = db_session.get(Frame, 1) photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() photo_widget.x, photo_widget.y, photo_widget.w, photo_widget.h = 0, 0, 4, 5 db_session.commit() second = 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(second) db_session.flush() from app.models import PhotoWidgetConfig db_session.add(PhotoWidgetConfig(widget_id=second.id)) db_session.commit() resp = client.get("/api/frames/1/buttons") assert resp.status_code == 200 data = resp.json() assert data["grid"] == {"cols": 8, "rows": 5} by_id = {w["id"]: w for w in data["widgets"]} assert by_id[photo_widget.id]["x"] == 0 and by_id[photo_widget.id]["w"] == 4 assert by_id[second.id]["x"] == 4 and by_id[second.id]["w"] == 4 def test_put_replaces_the_whole_list_in_order(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) frame = db_session.get(Frame, 1) photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() board_widget = _add_whiteboard_widget(db_session, frame) resp = client.put("/api/frames/1/buttons/next", json={ "actions": [ {"widget_id": board_widget.id, "action": "check_now"}, {"widget_id": photo_widget.id, "action": "advance"}, ], }, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text rows = db_session.query(FrameButtonAction).filter_by( frame_id=frame.id, button="next" ).order_by(FrameButtonAction.sort_order).all() assert [(r.widget_id, r.action) for r in rows] == [ (board_widget.id, "check_now"), (photo_widget.id, "advance"), ] # BACK's own default mapping (photos -> back) is untouched by a PUT to next. back_rows = db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="back").all() assert len(back_rows) == 1 assert back_rows[0].action == "back" def test_put_empty_list_clears_the_button(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) frame = db_session.get(Frame, 1) resp = client.put("/api/frames/1/buttons/next", json={"actions": []}, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="next").count() == 0 def test_put_rejects_unknown_button_name(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) resp = client.put("/api/frames/1/buttons/sideways", json={"actions": []}, headers=csrf_headers(client)) assert resp.status_code == 404 def test_put_rejects_widget_from_another_frame(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) other = Frame(name="Other", device_token="tok-other", manage_token="mtok-other", created_at=time.time()) db_session.add(other) db_session.flush() other_widget = Widget(frame_id=other.id, widget_type="photos", x=0, y=0, w=8, h=5, sort_order=0, created_at=time.time()) db_session.add(other_widget) db_session.commit() resp = client.put("/api/frames/1/buttons/next", json={ "actions": [{"widget_id": other_widget.id, "action": "advance"}], }, headers=csrf_headers(client)) assert resp.status_code == 400 # Nothing partially applied -- the whole request is validated before any write. assert db_session.query(FrameButtonAction).filter_by(frame_id=1, button="next").count() == 1 def test_put_rejects_action_the_widget_type_does_not_support(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) frame = db_session.get(Frame, 1) photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() resp = client.put("/api/frames/1/buttons/next", json={ "actions": [{"widget_id": photo_widget.id, "action": "check_now"}], }, headers=csrf_headers(client)) assert resp.status_code == 400 def test_deleting_a_widget_cascades_its_button_bindings(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) frame = db_session.get(Frame, 1) photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one() resp = client.delete(f"/api/frames/1/widgets/{photo_widget.id}", headers=csrf_headers(client)) assert resp.status_code == 200, resp.text assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id).count() == 0 def test_linked_user_can_view_and_control_can_save(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) bob = make_user(db_session, "bob") frame = db_session.get(Frame, 1) link_user(db_session, bob, frame) client.cookies.clear() login(client, "bob") resp = client.get("/api/frames/1/buttons") assert resp.status_code == 200 def test_unrelated_user_cannot_view(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) make_user(db_session, "mallory") client.cookies.clear() login(client, "mallory") resp = client.get("/api/frames/1/buttons") assert resp.status_code == 404