From 28a4ed3d384bd07e836a7b031f63a9ca868da591 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 21 May 2026 16:04:19 -0400 Subject: [PATCH] fc3d: settings API exposes scheduling knobs with bounds validation Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/settings.py | 25 +++++++++++++++ tests/test_api_settings.py | 62 +++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 33e3878..6ad49b8 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -22,6 +22,9 @@ _EDITABLE_FIELDS = ( "phash_threshold", "download_rate_limit_seconds", "download_validate_files", + "download_schedule_default_seconds", + "download_event_retention_days", + "download_failure_warning_threshold", ) @@ -42,6 +45,9 @@ async def get_import_settings(): "phash_threshold": row.phash_threshold, "download_rate_limit_seconds": row.download_rate_limit_seconds, "download_validate_files": row.download_validate_files, + "download_schedule_default_seconds": row.download_schedule_default_seconds, + "download_event_retention_days": row.download_event_retention_days, + "download_failure_warning_threshold": row.download_failure_warning_threshold, }) @@ -73,6 +79,25 @@ async def update_import_settings(): {"error": "download_validate_files must be a boolean"} ), 400 + # FC-3d scheduling knobs — bounds validation. + def _bad_int(name: str, lo: int, hi: int): + return jsonify( + {"error": f"{name} must be an integer in [{lo}, {hi}]"} + ), 400 + + if "download_schedule_default_seconds" in body: + v = body["download_schedule_default_seconds"] + if not isinstance(v, int) or isinstance(v, bool) or v < 60 or v > 86400: + return _bad_int("download_schedule_default_seconds", 60, 86400) + if "download_event_retention_days" in body: + v = body["download_event_retention_days"] + if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 3650: + return _bad_int("download_event_retention_days", 1, 3650) + if "download_failure_warning_threshold" in body: + v = body["download_failure_warning_threshold"] + if not isinstance(v, int) or isinstance(v, bool) or v < 1 or v > 100: + return _bad_int("download_failure_warning_threshold", 1, 100) + async with get_session() as session: row = ( await session.execute(select(ImportSettings).where(ImportSettings.id == 1)) diff --git a/tests/test_api_settings.py b/tests/test_api_settings.py index 5872a81..cea2466 100644 --- a/tests/test_api_settings.py +++ b/tests/test_api_settings.py @@ -82,6 +82,68 @@ async def test_system_stats_shape(client): assert status in body["tasks"] +# --- FC-3d: scheduling knobs ---------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_includes_scheduling_defaults(client): + resp = await client.get("/api/settings/import") + body = await resp.get_json() + assert body["download_schedule_default_seconds"] == 28800 + assert body["download_event_retention_days"] == 90 + assert body["download_failure_warning_threshold"] == 5 + + +@pytest.mark.asyncio +async def test_patch_scheduling_defaults(client): + resp = await client.patch("/api/settings/import", json={ + "download_schedule_default_seconds": 7200, + "download_event_retention_days": 30, + "download_failure_warning_threshold": 3, + }) + assert resp.status_code == 200 + body = await resp.get_json() + assert body["download_schedule_default_seconds"] == 7200 + assert body["download_event_retention_days"] == 30 + assert body["download_failure_warning_threshold"] == 3 + + +@pytest.mark.asyncio +async def test_patch_rejects_below_min_interval(client): + resp = await client.patch( + "/api/settings/import", + json={"download_schedule_default_seconds": 30}, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_patch_rejects_above_max_interval(client): + resp = await client.patch( + "/api/settings/import", + json={"download_schedule_default_seconds": 90000}, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_patch_rejects_zero_retention_days(client): + resp = await client.patch( + "/api/settings/import", + json={"download_event_retention_days": 0}, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +async def test_patch_rejects_zero_failure_threshold(client): + resp = await client.patch( + "/api/settings/import", + json={"download_failure_warning_threshold": 0}, + ) + assert resp.status_code == 400 + + @pytest.mark.asyncio async def test_system_stats_integrity_counts(client, db): from backend.app.models import ImageRecord