From d04983138a4f3d16eb11de2d3a246ebee5754196 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 24 May 2026 23:02:32 -0400 Subject: [PATCH] fc3h: /api/system/backup endpoint integration tests Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_api_system_backup.py | 335 ++++++++++++++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 tests/test_api_system_backup.py diff --git a/tests/test_api_system_backup.py b/tests/test_api_system_backup.py new file mode 100644 index 0000000..d1f36d0 --- /dev/null +++ b/tests/test_api_system_backup.py @@ -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/ -------------------------------------------------- + + +@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/ ------------------------------------------------ + + +@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//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/ ---------------------------------------------- + + +@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