Guard what a dashboard save does not change
`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
This commit is contained in:
@@ -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
|
||||
@@ -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<string, unknown> }[]
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
})
|
||||
Reference in New Issue
Block a user