"""POST /api/frames/{id}/widgets/{widget_id}/button-actions -- each widget's own NEXT/BACK binding editor (see routers/api_widgets.py's api_widget_button_actions and models.FrameButtonAction), replacing the old frame-level "Button assignments" card. Covers the CRUD/validation layer; multi-widget dispatch 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 each widget's own binding endpoint stores/validates what its dialog 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 _photo_widget_id(db_session) -> int: return db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").one().id def _shrink_default_widget(client, db_session, w=4, h=5) -> int: """Frees up the right-hand side of the grid for a second widget.""" widget_id = _photo_widget_id(db_session) resp = client.patch(f"/api/frames/1/widgets/{widget_id}", json={"x": 0, "y": 0, "w": w, "h": h}, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text return widget_id 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_new_widget_gets_the_sane_default_binding(client, db_session): """A brand new photos widget (via the API, not the migration backfill) should already have NEXT -> advance, BACK -> back from widgets.default_button_actions -- no separate save required.""" client.post("/setup", data={"username": "alice", "password": "hunter22"}) _shrink_default_widget(client, db_session) resp = client.post("/api/frames/1/widgets", json={"widget_type": "calendar"}, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text widget_id = resp.json()["id"] rows = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all() assert {r.button: r.action for r in rows} == {"next": "advance", "back": "back"} def test_new_whiteboard_widget_gets_check_now_on_both_buttons(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) _shrink_default_widget(client, db_session) resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"}, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text widget_id = resp.json()["id"] rows = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all() assert {r.button: r.action for r in rows} == {"next": "check_now", "back": "check_now"} def test_new_tasks_widget_gets_no_default_bindings(client, db_session): """tasks has an empty ACTIONS -- nothing sane to default to.""" client.post("/setup", data={"username": "alice", "password": "hunter22"}) _shrink_default_widget(client, db_session) resp = client.post("/api/frames/1/widgets", json={"widget_type": "tasks"}, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text widget_id = resp.json()["id"] assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).count() == 0 def test_save_updates_an_existing_binding(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) widget_id = _photo_widget_id(db_session) resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions", json={"next_button_action": "back", "back_button_action": "advance"}, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=widget_id)} assert rows == {"next": "back", "back": "advance"} def test_save_empty_string_clears_the_binding(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) widget_id = _photo_widget_id(db_session) resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions", json={"next_button_action": "", "back_button_action": "back"}, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=widget_id)} assert rows == {"back": "back"} def test_save_rejects_action_the_widget_type_does_not_support(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) widget_id = _photo_widget_id(db_session) resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions", json={"next_button_action": "check_now", "back_button_action": ""}, headers=csrf_headers(client)) assert resp.status_code == 400 # Nothing partially applied -- the old default binding is untouched. assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").one().action == "advance" def test_save_is_one_binding_per_widget_per_button(client, db_session): """Saving twice for the same button updates the one row in place, it never accumulates a second row (matches the new unique index on (widget_id, button)).""" client.post("/setup", data={"username": "alice", "password": "hunter22"}) frame = db_session.get(Frame, 1) widget_id = _photo_widget_id(db_session) for action in ("advance", "back", "advance"): resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions", json={"next_button_action": action, "back_button_action": "back"}, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").count() == 1 def test_save_404s_for_unknown_widget(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) resp = client.post("/api/frames/1/widgets/999999/button-actions", json={"next_button_action": "advance", "back_button_action": "back"}, headers=csrf_headers(client)) assert resp.status_code == 404 def test_save_unrelated_user_404s(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) make_user(db_session, "mallory") widget_id = _photo_widget_id(db_session) client.cookies.clear() login(client, "mallory") resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions", json={"next_button_action": "back", "back_button_action": "advance"}, headers=csrf_headers(client)) assert resp.status_code == 404 assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").one().action == "advance" def test_save_linked_but_not_controlling_user_409s(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) widget_id = _photo_widget_id(db_session) client.cookies.clear() login(client, "bob") resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions", json={"next_button_action": "back", "back_button_action": "advance"}, headers=csrf_headers(client)) assert resp.status_code == 409 assert resp.json()["detail"]["error"] == "not_controller" def test_two_widgets_of_the_same_type_have_independent_bindings(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) photo_widget_id = _photo_widget_id(db_session) frame = db_session.get(Frame, 1) board = _add_whiteboard_widget(db_session, frame) resp = client.post(f"/api/frames/1/widgets/{board.id}/button-actions", json={"next_button_action": "check_now", "back_button_action": ""}, headers=csrf_headers(client)) assert resp.status_code == 200, resp.text photo_rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=photo_widget_id)} board_rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=board.id)} assert photo_rows == {"next": "advance", "back": "back"} assert board_rows == {"next": "check_now"}