Files
DumiFlex e3caa710f9 fix(backups): restore a backup at once, without restarting ComfyUI (#247)
Restore copied the backup file over the library at the next start because swapping a SQLite file under open connections corrupts it. It now copies the backup into the open library through SQLite's online backup API, after checking it and saving the library as a pre-restore backup, then migrates it. The Backups panel refreshes the library and offers Undo.
2026-11-03 19:27:50 +01:00

198 lines
7.6 KiB
Python

"""/wp/api/settings + /wp/api/database/backups/*."""
from __future__ import annotations
import os
from pathlib import Path
from engine import prefs
from engine.db import backups as bk
def _db_path() -> Path:
return Path(os.environ["WP_DB_PATH"])
# ── settings ─────────────────────────────────────────────────────────
async def test_get_settings_defaults(wp_client):
resp = await wp_client.get("/wp/api/settings")
assert resp.status == 200
assert await resp.json() == {
"max_ref_depth": 8,
"backups": {"enabled": True, "keep": 7, "daily": True},
}
async def test_put_settings_partial_merge(wp_client):
resp = await wp_client.put("/wp/api/settings", json={"backups": {"keep": 3}})
assert resp.status == 200
body = await resp.json()
assert body["backups"] == {"enabled": True, "keep": 3, "daily": True}
resp = await wp_client.put("/wp/api/settings", json={"max_ref_depth": 100})
body = await resp.json()
assert body["max_ref_depth"] == 32
assert body["backups"]["keep"] == 3
assert prefs.load() == body
async def test_put_settings_rejects_non_object(wp_client):
resp = await wp_client.put("/wp/api/settings", json=[1, 2])
assert resp.status == 400
resp = await wp_client.put("/wp/api/settings", data="nope")
assert resp.status == 400
# ── backups ──────────────────────────────────────────────────────────
async def test_list_backups_empty(wp_client):
resp = await wp_client.get("/wp/api/database/backups")
assert resp.status == 200
body = await resp.json()
assert body == {
"dir": str(_db_path().parent / "backups"),
"backups": [],
"pending_restore": None,
}
async def test_create_list_delete_backup(wp_client):
resp = await wp_client.post("/wp/api/database/backups")
assert resp.status == 201
entry = await resp.json()
assert entry.pop("pruned") == []
assert entry["reason"] == "manual"
assert set(entry) == {"name", "size", "created_at", "reason", "pinned", "note"}
assert entry["note"] is None
body = await (await wp_client.get("/wp/api/database/backups")).json()
assert body["backups"] == [entry]
resp = await wp_client.delete(f"/wp/api/database/backups/{entry['name']}")
assert resp.status == 204
resp = await wp_client.delete(f"/wp/api/database/backups/{entry['name']}")
assert resp.status == 404
async def test_manual_backup_prunes_to_keep(wp_client):
await wp_client.put("/wp/api/settings", json={"backups": {"keep": 2}})
names = [(await (await wp_client.post("/wp/api/database/backups")).json())["name"]
for _ in range(2)]
resp = await wp_client.put(f"/wp/api/database/backups/{names[0]}/pin", json={"pinned": True})
assert resp.status == 200
assert (await resp.json())["pinned"] is True
third = await (await wp_client.post("/wp/api/database/backups")).json()
assert third["pruned"] == []
fourth = await (await wp_client.post("/wp/api/database/backups")).json()
assert fourth["pruned"] == [names[1]]
listed = (await (await wp_client.get("/wp/api/database/backups")).json())["backups"]
assert [b["name"] for b in listed] == [fourth["name"], third["name"], names[0]]
async def test_manual_backup_takes_a_note(wp_client):
resp = await wp_client.post("/wp/api/database/backups", json={"note": " before the rework "})
assert resp.status == 201
assert (await resp.json())["note"] == "before the rework"
assert (await wp_client.post("/wp/api/database/backups", json={"note": 3})).status == 400
assert (await wp_client.post("/wp/api/database/backups", data="nope")).status == 400
async def test_pin_backup_validates(wp_client):
entry = await (await wp_client.post("/wp/api/database/backups")).json()
url = f"/wp/api/database/backups/{entry['name']}/pin"
assert (await wp_client.put(url, json={"pinned": "yes"})).status == 400
assert (await wp_client.put(url, data="nope")).status == 400
missing = "/wp/api/database/backups/wildcard-pipeline-20200101-000000-manual.db/pin"
assert (await wp_client.put(missing, json={"pinned": True})).status == 404
resp = await wp_client.put(url, json={"pinned": False})
assert (await resp.json())["pinned"] is False
async def test_delete_backup_traversal_is_404(wp_client):
resp = await wp_client.delete("/wp/api/database/backups/..%2Fapi.db")
assert resp.status == 404
assert _db_path().exists()
def _category_names() -> list[str]:
import sqlite3
conn = sqlite3.connect(str(_db_path()))
try:
return [r[0] for r in conn.execute("SELECT name FROM module_categories ORDER BY name")]
finally:
conn.close()
def _add_category(name: str) -> None:
import sqlite3
conn = sqlite3.connect(str(_db_path()))
with conn:
conn.execute("INSERT INTO module_categories(id, name) VALUES (?, ?)", (name, name))
conn.close()
async def test_restore_runs_live(wp_client):
_add_category("kept")
entry = await (await wp_client.post("/wp/api/database/backups")).json()
_add_category("later")
resp = await wp_client.post(
"/wp/api/database/backups/restore", json={"name": entry["name"]},
)
assert resp.status == 200
body = await resp.json()
assert body["restored"] == entry["name"]
assert "pre-restore" in body["pre_restore"]
assert _category_names() == ["kept"] # no restart needed
# The library is usable through the API straight away.
resp = await wp_client.get("/wp/api/categories")
assert resp.status == 200
listing = await (await wp_client.get("/wp/api/database/backups")).json()
assert listing["pending_restore"] is None
assert {b["name"] for b in listing["backups"]} >= {entry["name"], body["pre_restore"]}
async def test_restore_refused_is_409(wp_client):
import sqlite3
entry = await (await wp_client.post("/wp/api/database/backups")).json()
conn = sqlite3.connect(str(bk.backups_dir(_db_path()) / entry["name"]))
with conn:
conn.execute("INSERT INTO migrations(version, applied_at) VALUES (999, 'x')")
conn.close()
resp = await wp_client.post(
"/wp/api/database/backups/restore", json={"name": entry["name"]},
)
assert resp.status == 409
assert "newer version" in (await resp.json())["error"]
async def test_cancel_a_restore_an_older_build_staged(wp_client):
entry = await (await wp_client.post("/wp/api/database/backups")).json()
bk.stage_restore(_db_path(), entry["name"])
body = await (await wp_client.get("/wp/api/database/backups")).json()
assert body["pending_restore"] == entry["name"]
resp = await wp_client.delete("/wp/api/database/backups/restore")
assert resp.status == 200
assert await resp.json() == {"pending_restore": None}
body = await (await wp_client.get("/wp/api/database/backups")).json()
assert body["pending_restore"] is None
async def test_restore_unknown_is_404(wp_client):
resp = await wp_client.post(
"/wp/api/database/backups/restore",
json={"name": "wildcard-pipeline-20260101-000000-manual.db"},
)
assert resp.status == 404
resp = await wp_client.post(
"/wp/api/database/backups/restore", json={"name": "../api.db"},
)
assert resp.status == 404
async def test_restore_bad_body_is_400(wp_client):
resp = await wp_client.post("/wp/api/database/backups/restore", json={})
assert resp.status == 400
resp = await wp_client.post("/wp/api/database/backups/restore", data="x")
assert resp.status == 400