fc3h: /api/system/backup endpoint integration tests
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
"""FC-3h: /api/system/backup/* endpoint integration tests."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app import create_app
|
||||
from backend.app.models import BackupRun
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app():
|
||||
return create_app()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app):
|
||||
async with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def _seed_runs(db):
|
||||
"""Insert 4 BackupRun rows for paging/filter tests."""
|
||||
now = datetime.now(UTC)
|
||||
for i in range(4):
|
||||
db.add(BackupRun(
|
||||
kind="db" if i % 2 == 0 else "images",
|
||||
status="ok",
|
||||
tag=None,
|
||||
triggered_by="manual",
|
||||
started_at=now - timedelta(seconds=10 - i),
|
||||
finished_at=now - timedelta(seconds=9 - i),
|
||||
sql_path="/tmp/fake.sql" if i % 2 == 0 else None,
|
||||
tar_path=None if i % 2 == 0 else "/tmp/fake.tar.zst",
|
||||
size_bytes=100,
|
||||
manifest={},
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# --- POST /db, /images ----------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_db_dispatches_and_returns_202(client, monkeypatch):
|
||||
dispatched = []
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.backup.backup_db_task.delay",
|
||||
lambda **kw: dispatched.append(kw),
|
||||
)
|
||||
resp = await client.post("/api/system/backup/db", json={})
|
||||
assert resp.status_code == 202
|
||||
body = await resp.get_json()
|
||||
assert body["status"] == "dispatched"
|
||||
assert dispatched == [{"tag": None, "triggered_by": "manual"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_db_persists_tag(client, monkeypatch):
|
||||
dispatched = []
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.backup.backup_db_task.delay",
|
||||
lambda **kw: dispatched.append(kw),
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/system/backup/db", json={"tag": "pre-cutover"},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
assert dispatched[0]["tag"] == "pre-cutover"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_db_rejects_tag_too_long(client):
|
||||
resp = await client.post(
|
||||
"/api/system/backup/db", json={"tag": "x" * 65},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
body = await resp.get_json()
|
||||
assert body["error"] == "invalid_tag"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_images_dispatches(client, monkeypatch):
|
||||
dispatched = []
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.backup.backup_images_task.delay",
|
||||
lambda **kw: dispatched.append(kw),
|
||||
)
|
||||
resp = await client.post("/api/system/backup/images", json={})
|
||||
assert resp.status_code == 202
|
||||
|
||||
|
||||
# --- GET /runs -------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runs_paginated_descending(client, _seed_runs):
|
||||
resp = await client.get("/api/system/backup/runs?limit=2")
|
||||
body = await resp.get_json()
|
||||
assert len(body["runs"]) == 2
|
||||
ids = [r["id"] for r in body["runs"]]
|
||||
assert ids == sorted(ids, reverse=True)
|
||||
assert body["next_cursor"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runs_filter_by_kind_db(client, _seed_runs):
|
||||
resp = await client.get("/api/system/backup/runs?kind=db")
|
||||
body = await resp.get_json()
|
||||
assert all(r["kind"] == "db" for r in body["runs"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runs_filter_by_kind_images(client, _seed_runs):
|
||||
resp = await client.get("/api/system/backup/runs?kind=images")
|
||||
body = await resp.get_json()
|
||||
assert all(r["kind"] == "images" for r in body["runs"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runs_rejects_invalid_kind(client):
|
||||
resp = await client.get("/api/system/backup/runs?kind=bogus")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runs_rejects_invalid_limit(client):
|
||||
resp = await client.get("/api/system/backup/runs?limit=not-int")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# --- GET /runs/<id> --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_run_returns_row(client, _seed_runs):
|
||||
list_resp = await client.get("/api/system/backup/runs?limit=1")
|
||||
rid = (await list_resp.get_json())["runs"][0]["id"]
|
||||
resp = await client.get(f"/api/system/backup/runs/{rid}")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["id"] == rid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_run_404(client):
|
||||
resp = await client.get("/api/system/backup/runs/999999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- PATCH /runs/<id> ------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_tag_sets_value(client, _seed_runs, db_sync):
|
||||
list_resp = await client.get("/api/system/backup/runs?limit=1")
|
||||
rid = (await list_resp.get_json())["runs"][0]["id"]
|
||||
|
||||
resp = await client.patch(
|
||||
f"/api/system/backup/runs/{rid}",
|
||||
json={"tag": "monthly"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
tag = db_sync.execute(
|
||||
select(BackupRun.tag).where(BackupRun.id == rid)
|
||||
).scalar_one()
|
||||
assert tag == "monthly"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_tag_null_clears(client, _seed_runs, db_sync):
|
||||
list_resp = await client.get("/api/system/backup/runs?limit=1")
|
||||
rid = (await list_resp.get_json())["runs"][0]["id"]
|
||||
|
||||
await client.patch(f"/api/system/backup/runs/{rid}", json={"tag": "x"})
|
||||
await client.patch(f"/api/system/backup/runs/{rid}", json={"tag": None})
|
||||
|
||||
tag = db_sync.execute(
|
||||
select(BackupRun.tag).where(BackupRun.id == rid)
|
||||
).scalar_one()
|
||||
assert tag is None
|
||||
|
||||
|
||||
# --- POST /runs/<id>/restore ----------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_wrong_confirm_400(client, _seed_runs):
|
||||
list_resp = await client.get("/api/system/backup/runs?kind=db&limit=1")
|
||||
rid = (await list_resp.get_json())["runs"][0]["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/system/backup/runs/{rid}/restore",
|
||||
json={"confirm": "wrong"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
body = await resp.get_json()
|
||||
assert body["error"] == "confirm_mismatch"
|
||||
assert body["expected"] == f"restore-db-{rid}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_correct_confirm_dispatches(client, _seed_runs, monkeypatch):
|
||||
list_resp = await client.get("/api/system/backup/runs?kind=db&limit=1")
|
||||
rid = (await list_resp.get_json())["runs"][0]["id"]
|
||||
|
||||
dispatched = []
|
||||
monkeypatch.setattr(
|
||||
"backend.app.tasks.backup.restore_db_task.delay",
|
||||
lambda **kw: dispatched.append(kw),
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/system/backup/runs/{rid}/restore",
|
||||
json={"confirm": f"restore-db-{rid}"},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
assert dispatched == [{"source_backup_run_id": rid}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_rejects_non_ok_status(client, db):
|
||||
# Seed an error-status row directly.
|
||||
db.add(BackupRun(
|
||||
kind="db", status="error", tag=None, triggered_by="manual",
|
||||
started_at=datetime.now(UTC), finished_at=datetime.now(UTC),
|
||||
sql_path="/tmp/fake.sql", manifest={},
|
||||
))
|
||||
await db.commit()
|
||||
rid_q = await client.get("/api/system/backup/runs?kind=db&limit=1")
|
||||
rid = (await rid_q.get_json())["runs"][0]["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/system/backup/runs/{rid}/restore",
|
||||
json={"confirm": f"restore-db-{rid}"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
body = await resp.get_json()
|
||||
assert body["error"] == "not_restorable"
|
||||
|
||||
|
||||
# --- DELETE /runs/<id> ----------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_wrong_confirm_400(client, _seed_runs):
|
||||
list_resp = await client.get("/api/system/backup/runs?limit=1")
|
||||
rid = (await list_resp.get_json())["runs"][0]["id"]
|
||||
|
||||
resp = await client.delete(
|
||||
f"/api/system/backup/runs/{rid}",
|
||||
json={"confirm": "wrong"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_correct_confirm_204(client, _seed_runs, db_sync):
|
||||
list_resp = await client.get("/api/system/backup/runs?limit=1")
|
||||
row = (await list_resp.get_json())["runs"][0]
|
||||
rid = row["id"]; kind = row["kind"]
|
||||
|
||||
resp = await client.delete(
|
||||
f"/api/system/backup/runs/{rid}",
|
||||
json={"confirm": f"delete-{kind}-{rid}"},
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
|
||||
surviving = db_sync.execute(
|
||||
select(BackupRun.id).where(BackupRun.id == rid)
|
||||
).scalar_one_or_none()
|
||||
assert surviving is None
|
||||
|
||||
|
||||
# --- /settings ------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_settings_returns_defaults(client):
|
||||
resp = await client.get("/api/system/backup/settings")
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["backup_db_nightly_enabled"] is False
|
||||
assert body["backup_db_nightly_hour_utc"] == 3
|
||||
assert body["backup_db_keep_last_n"] == 14
|
||||
assert body["backup_images_keep_last_n"] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_settings_updates_values(client):
|
||||
resp = await client.patch(
|
||||
"/api/system/backup/settings",
|
||||
json={
|
||||
"backup_db_nightly_enabled": True,
|
||||
"backup_db_nightly_hour_utc": 5,
|
||||
"backup_db_keep_last_n": 30,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["backup_db_nightly_enabled"] is True
|
||||
assert body["backup_db_nightly_hour_utc"] == 5
|
||||
assert body["backup_db_keep_last_n"] == 30
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_settings_rejects_invalid_bool(client):
|
||||
resp = await client.patch(
|
||||
"/api/system/backup/settings",
|
||||
json={"backup_db_nightly_enabled": "yes"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_settings_rejects_hour_out_of_range(client):
|
||||
resp = await client.patch(
|
||||
"/api/system/backup/settings",
|
||||
json={"backup_db_nightly_hour_utc": 24},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_settings_rejects_keep_n_below_min(client):
|
||||
resp = await client.patch(
|
||||
"/api/system/backup/settings",
|
||||
json={"backup_db_keep_last_n": 0},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
Reference in New Issue
Block a user