From 7600aaf7eace3a86d54fa60d083cbf9adcfae2f1 Mon Sep 17 00:00:00 2001 From: stroblme Date: Fri, 28 Aug 2026 12:59:08 +0200 Subject: [PATCH] Guard what a dashboard save does not change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PUT /dashboards/{name}` had no backend test: three cover what it promises — a first draft for a name nobody has used (200, not the 404 that came off in 4a2337f), an update that keeps everything the edit did not name, and a save based on a version someone moved past. The Playwright half is the same property through the editor. Pages and sections are gone since 7ff29ca, so what the editor never draws — a widget's md/sm placements and the dashboard-wide settings — is what a save has to carry, and dragging one widget is what the spec makes it carry it through. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk --- backend/tests/api/routes/test_dashboards.py | 88 +++++++++++++++ frontend/tests/persistence.spec.ts | 118 ++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 backend/tests/api/routes/test_dashboards.py create mode 100644 frontend/tests/persistence.spec.ts diff --git a/backend/tests/api/routes/test_dashboards.py b/backend/tests/api/routes/test_dashboards.py new file mode 100644 index 0000000..609c376 --- /dev/null +++ b/backend/tests/api/routes/test_dashboards.py @@ -0,0 +1,88 @@ +"""Dashboards over HTTP: saving one, which is also how the first one is made.""" + +from fastapi.testclient import TestClient + +from fluksio.core.config import settings + +PREFIX = f"{settings.API_V1_STR}/dashboards" + + +def a_dashboard(name: str) -> dict: + return { + "name": name, + "title": "Hall", + "icon": "gauge", + "widgets": [ + { + "id": "temperature", + "type": "stat", + "title": "Temperature", + "layout": {"lg": {"x": 0, "y": 0, "w": 3, "h": 2}}, + "config": {"message": "house.temperature", "dtype": "float"}, + } + ], + "settings": {"theme": {"value": "dark", "message": "", "dtype": "str"}}, + "version": 0, + } + + +def test_a_save_creates_a_dashboard_that_does_not_exist_yet( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + """A first draft, not a 404: creating one *is* saving it at version 0.""" + body = a_dashboard("put_creates") + + response = client.put( + f"{PREFIX}/put_creates", headers=superuser_token_headers, json=body + ) + + assert response.status_code == 200, response.text + saved = response.json() + assert saved["version"] == 1 and saved["has_draft"] is True + # A draft alone: nothing was published, so no panel can be shown it. + assert ( + client.get(f"{PREFIX}/put_creates", headers=superuser_token_headers).status_code + == 404 + ) + draft = client.get( + f"{PREFIX}/put_creates?draft=true", headers=superuser_token_headers + ) + assert draft.json()["widgets"] == body["widgets"] + + +def test_a_save_of_an_existing_dashboard_keeps_what_it_did_not_change( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + seeded = a_dashboard("put_updates") + first = client.put( + f"{PREFIX}/put_updates", headers=superuser_token_headers, json=seeded + ).json() + + renamed = {**first, "widgets": [{**first["widgets"][0], "title": "Outside"}]} + second = client.put( + f"{PREFIX}/put_updates", headers=superuser_token_headers, json=renamed + ) + + assert second.status_code == 200, second.text + stored = second.json() + assert stored["version"] == first["version"] + 1 + assert stored["widgets"][0]["title"] == "Outside" + # Everything the edit did not name is still what was first written. + assert stored["icon"] == seeded["icon"] + assert stored["settings"] == seeded["settings"] + assert stored["widgets"][0]["layout"] == seeded["widgets"][0]["layout"] + assert stored["widgets"][0]["config"] == seeded["widgets"][0]["config"] + + +def test_a_save_based_on_a_version_someone_moved_past_is_refused( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + body = a_dashboard("put_conflicts") + client.put(f"{PREFIX}/put_conflicts", headers=superuser_token_headers, json=body) + + stale = client.put( + f"{PREFIX}/put_conflicts", headers=superuser_token_headers, json=body + ) + + assert stale.status_code == 409 + assert stale.json()["detail"]["current_version"] == 1 diff --git a/frontend/tests/persistence.spec.ts b/frontend/tests/persistence.spec.ts new file mode 100644 index 0000000..6a1a1af --- /dev/null +++ b/frontend/tests/persistence.spec.ts @@ -0,0 +1,118 @@ +import { expect, type Page, test } from "@playwright/test" +import { api, apiPage, deleteAll } from "./utils/api" + +/** + * An editor save is a rewrite of the whole document, not a patch of what moved. + * + * So everything the editor never draws rides on that one request: a widget's + * placements at the narrower breakpoints, which only a panel of that width + * reads, and the dashboard-wide settings, which live in the settings panel + * rather than on the canvas. `applyLayout` keeps them by spreading the stored + * layout under the one column set it knows; a save that forgot to would lose a + * phone's arrangement with nothing on screen to show for it. + */ + +const dashboardName = `test_persist_${Date.now().toString(36)}` + +/** The tile the editor is told to move. */ +const MOVED = { + id: "moved", + type: "clock", + title: "Moved", + layout: { lg: { x: 0, y: 0, w: 3, h: 3 } }, + config: {}, +} + +/** The tile nothing touches. Its stored form is the assertion. */ +const KEPT = { + id: "kept", + type: "stat", + title: "Kept", + layout: { + lg: { x: 8, y: 0, w: 4, h: 3 }, + md: { x: 0, y: 6, w: 5, h: 3 }, + sm: { x: 0, y: 9, w: 2, h: 2 }, + }, + config: { message: "house.kept", dtype: "float", unit: "°C" }, +} + +/** Dashboard-wide, and nowhere on the canvas the drag happens on. */ +const SETTINGS = { + theme: { value: "dark", message: "", dtype: "str" }, + touch: { value: true, message: "", dtype: "bool" }, +} + +test.use({ storageState: "playwright/.auth/user.json" }) + +test.describe.configure({ mode: "serial" }) + +test.beforeAll(async ({ browser }) => { + const page = await apiPage(browser) + // Version 0 creates: a first draft is what a save of a name nobody has used + // yet means, and the editor reads the draft. + const made = await api(page, `/dashboards/${dashboardName}`, { + method: "PUT", + data: { + name: dashboardName, + title: "Persistence", + icon: "layout-dashboard", + columns: 12, + canvas_width: 1920, + canvas_height: 1080, + version: 0, + widgets: [MOVED, KEPT], + settings: SETTINGS, + }, + }) + if (!made.ok()) + throw new Error(`dashboard PUT ${made.status()}: ${await made.text()}`) + await page.close() +}) + +test.afterAll(async ({ browser }) => { + await deleteAll(browser, [`/dashboards/${dashboardName}`]) +}) + +/** The draft as stored — what a panel would be shown once it is published. */ +async function stored(page: Page) { + const response = await api(page, `/dashboards/${dashboardName}?draft=true`) + return (await response.json()) as { + icon: string + settings: unknown + widgets: { id: string; layout: Record }[] + } +} + +test("moving one widget leaves the rest of the document alone", async ({ + page, +}) => { + await page.goto(`/dashboards/${dashboardName}?edit=true`) + const moved = page.getByTestId("widget-frame").filter({ hasText: "Moved" }) + await moved.waitFor({ state: "visible", timeout: 15000 }) + + // Drag it a tile's width to the right, which is clear of `kept` at column 8. + const box = (await moved.boundingBox())! + await moved.locator(".widget-grip").hover() + await page.mouse.down() + await page.mouse.move(box.x + box.width, box.y + box.height / 4, { + steps: 10, + }) + await page.mouse.up() + + // The save is debounced, so wait for the store rather than for the canvas. + await expect + .poll( + async () => + ( + (await stored(page)).widgets.find((w) => w.id === "moved")?.layout + .lg as { x: number } + )?.x, + { message: "the drag was never saved", timeout: 10000 }, + ) + .toBeGreaterThan(0) + + const after = await stored(page) + expect(after.widgets.find((w) => w.id === "kept")).toEqual(KEPT) + expect(after.settings).toEqual(SETTINGS) + expect(after.icon).toBe("layout-dashboard") +})