diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index 73543c1..d548306 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -620,6 +620,13 @@ async def adapters_edit_form( "SELECT alias FROM config.api_keys ORDER BY alias" ) + # Get map tile settings from config.system + sys_row = await conn.fetchrow( + "SELECT map_tile_url, map_attribution FROM config.system WHERE id = true" + ) + tile_url = sys_row["map_tile_url"] if sys_row else "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" + tile_attribution = sys_row["map_attribution"] if sys_row else "© OpenStreetMap contributors" + settings = row["settings"] or {} adapter = { "name": row["name"], @@ -643,6 +650,8 @@ async def adapters_edit_form( "api_keys": [{"alias": k["alias"]} for k in api_keys], "valid_satellites": _get_valid_satellites(), "valid_feeds": sorted(_get_valid_feeds()), + "tile_url": tile_url, + "tile_attribution": tile_attribution, }, ) csrf_protect.set_csrf_cookie(signed_token, response) @@ -749,6 +758,39 @@ async def adapters_edit_submit( else: new_settings["feed"] = feed + # Region validation (applies to all adapters) + region_north_str = form.get("region_north", "").strip() + region_south_str = form.get("region_south", "").strip() + region_east_str = form.get("region_east", "").strip() + region_west_str = form.get("region_west", "").strip() + + form_data["region_north"] = region_north_str + form_data["region_south"] = region_south_str + form_data["region_east"] = region_east_str + form_data["region_west"] = region_west_str + + try: + region_north = float(region_north_str) + region_south = float(region_south_str) + region_east = float(region_east_str) + region_west = float(region_west_str) + + # Validate latitude bounds + if not (-90 <= region_south < region_north <= 90): + errors["region"] = "Invalid latitude: south must be less than north, both between -90 and 90" + # Validate longitude bounds + elif not (-180 <= region_west < region_east <= 180): + errors["region"] = "Invalid longitude: west must be less than east, both between -180 and 180" + else: + new_settings["region"] = { + "north": region_north, + "south": region_south, + "east": region_east, + "west": region_west, + } + except ValueError: + errors["region"] = "Region coordinates must be valid numbers" + # If there are errors, re-render the form if errors: adapter = { @@ -764,6 +806,13 @@ async def adapters_edit_submit( "SELECT alias FROM config.api_keys ORDER BY alias" ) + # Get map tile settings for re-render + sys_row = await conn.fetchrow( + "SELECT map_tile_url, map_attribution FROM config.system WHERE id = true" + ) + tile_url = sys_row["map_tile_url"] if sys_row else "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" + tile_attribution = sys_row["map_attribution"] if sys_row else "© OpenStreetMap contributors" + csrf_token, signed_token = csrf_protect.generate_csrf_tokens() response = templates.TemplateResponse( request=request, @@ -777,6 +826,8 @@ async def adapters_edit_submit( "api_keys": [{"alias": k["alias"]} for k in api_keys], "valid_satellites": _get_valid_satellites(), "valid_feeds": sorted(_get_valid_feeds()), + "tile_url": tile_url, + "tile_attribution": tile_attribution, }, status_code=200, ) diff --git a/src/central/gui/templates/_region_picker.html b/src/central/gui/templates/_region_picker.html new file mode 100644 index 0000000..5c53bc9 --- /dev/null +++ b/src/central/gui/templates/_region_picker.html @@ -0,0 +1,112 @@ +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {% if errors and errors.region %} + {{ errors.region }} + {% endif %} + + +
+ + diff --git a/src/central/gui/templates/adapters_edit.html b/src/central/gui/templates/adapters_edit.html index fe7e093..35ae6c8 100644 --- a/src/central/gui/templates/adapters_edit.html +++ b/src/central/gui/templates/adapters_edit.html @@ -2,6 +2,13 @@ {% block title %}Central — Edit {{ adapter.name }}{% endblock %} +{% block head %} + + + + +{% endblock %} + {% block content %}

Edit Adapter: {{ adapter.name }}

@@ -29,18 +36,8 @@
- Region (read-only) - {% if adapter.settings.region %} -

- North: {{ adapter.settings.region.north }}
- South: {{ adapter.settings.region.south }}
- East: {{ adapter.settings.region.east }}
- West: {{ adapter.settings.region.west }} -

- {% else %} -

No region configured.

- {% endif %} - Region editing comes in 1b-5. + Region + {% include "_region_picker.html" %}
diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 2f96e0b..17352f0 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -84,14 +84,17 @@ class TestAdaptersEditForm: mock_request.state.operator = MagicMock(id=1, username="testop") mock_conn = AsyncMock() - mock_conn.fetchrow.return_value = { - "name": "nws", - "enabled": True, - "cadence_s": 60, - "settings": {"contact_email": "test@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}}, - "paused_at": None, - "updated_at": None, - } + mock_conn.fetchrow.side_effect = [ + { + "name": "nws", + "enabled": True, + "cadence_s": 60, + "settings": {"contact_email": "test@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}}, + "paused_at": None, + "updated_at": None, + }, + {"map_tile_url": "https://tile.example.com/{z}/{x}/{y}.png", "map_attribution": "Test"}, + ] mock_conn.fetch.return_value = [] # No API keys mock_pool = MagicMock() @@ -156,6 +159,10 @@ class TestAdaptersEditSubmit: mock_form.get.side_effect = lambda k, d="": { "cadence_s": "120", "contact_email": "new@example.com", + "region_north": "49.0", + "region_south": "24.0", + "region_east": "-66.0", + "region_west": "-125.0", }.get(k, d) mock_form.getlist.return_value = [] mock_form.__contains__ = lambda self, k: k == "enabled" @@ -166,7 +173,7 @@ class TestAdaptersEditSubmit: "name": "nws", "enabled": True, "cadence_s": 60, - "settings": {"contact_email": "old@example.com"}, + "settings": {"contact_email": "old@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}}, "paused_at": None, "updated_at": None, } @@ -200,20 +207,27 @@ class TestAdaptersEditSubmit: mock_form.get.side_effect = lambda k, d="": { "cadence_s": "30", "contact_email": "test@example.com", + "region_north": "49.0", + "region_south": "24.0", + "region_east": "-66.0", + "region_west": "-125.0", }.get(k, d) mock_form.getlist.return_value = [] mock_form.__contains__ = lambda self, k: k == "enabled" mock_request.form = AsyncMock(return_value=mock_form) mock_conn = AsyncMock() - mock_conn.fetchrow.return_value = { - "name": "nws", - "enabled": True, - "cadence_s": 60, - "settings": {"contact_email": "test@example.com"}, - "paused_at": None, - "updated_at": None, - } + mock_conn.fetchrow.side_effect = [ + { + "name": "nws", + "enabled": True, + "cadence_s": 60, + "settings": {"contact_email": "test@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}}, + "paused_at": None, + "updated_at": None, + }, + {"map_tile_url": None, "map_attribution": None}, # system settings for re-render + ] mock_conn.fetch.return_value = [] mock_pool = MagicMock() @@ -252,6 +266,10 @@ class TestAdaptersEditSubmit: mock_form.get.side_effect = lambda k, d="": { "cadence_s": "300", "api_key_alias": "nonexistent_key", + "region_north": "49.5", + "region_south": "31.0", + "region_east": "-102.0", + "region_west": "-124.5", }.get(k, d) mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"] mock_form.__contains__ = lambda self, k: k == "enabled" @@ -263,11 +281,12 @@ class TestAdaptersEditSubmit: "name": "firms", "enabled": True, "cadence_s": 300, - "settings": {"api_key_alias": "firms", "satellites": ["VIIRS_SNPP_NRT"]}, + "settings": {"api_key_alias": "firms", "satellites": ["VIIRS_SNPP_NRT"], "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}}, "paused_at": None, "updated_at": None, }, None, # Second call: check api_key exists - returns None + {"map_tile_url": None, "map_attribution": None}, # system settings for re-render ] mock_conn.fetch.return_value = [] @@ -306,20 +325,27 @@ class TestAdaptersEditSubmit: mock_form.get.side_effect = lambda k, d="": { "cadence_s": "120", "feed": "invalid_feed", + "region_north": "49.0", + "region_south": "24.0", + "region_east": "-66.0", + "region_west": "-125.0", }.get(k, d) mock_form.getlist.return_value = [] mock_form.__contains__ = lambda self, k: k == "enabled" mock_request.form = AsyncMock(return_value=mock_form) mock_conn = AsyncMock() - mock_conn.fetchrow.return_value = { - "name": "usgs_quake", - "enabled": True, - "cadence_s": 120, - "settings": {"feed": "all_hour"}, - "paused_at": None, - "updated_at": None, - } + mock_conn.fetchrow.side_effect = [ + { + "name": "usgs_quake", + "enabled": True, + "cadence_s": 120, + "settings": {"feed": "all_hour", "region": {"north": 49, "south": 24, "east": -66, "west": -125}}, + "paused_at": None, + "updated_at": None, + }, + {"map_tile_url": None, "map_attribution": None}, # system settings for re-render + ] mock_conn.fetch.return_value = [] mock_pool = MagicMock() @@ -360,6 +386,10 @@ class TestAdaptersAudit: mock_form.get.side_effect = lambda k, d="": { "cadence_s": "120", "contact_email": "new@example.com", + "region_north": "49.0", + "region_south": "24.0", + "region_east": "-66.0", + "region_west": "-125.0", }.get(k, d) mock_form.getlist.return_value = [] mock_form.__contains__ = lambda self, k: k == "enabled" @@ -370,7 +400,7 @@ class TestAdaptersAudit: "name": "nws", "enabled": True, "cadence_s": 60, - "settings": {"contact_email": "old@example.com"}, + "settings": {"contact_email": "old@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}}, "paused_at": None, "updated_at": None, } @@ -422,6 +452,10 @@ class TestAdaptersJsonbRegression: mock_form.get.side_effect = lambda k, d="": { "cadence_s": "120", "contact_email": "test@example.com", + "region_north": "49.0", + "region_south": "24.0", + "region_east": "-66.0", + "region_west": "-125.0", }.get(k, d) mock_form.getlist.return_value = [] mock_form.__contains__ = lambda self, k: k == "enabled" @@ -432,7 +466,7 @@ class TestAdaptersJsonbRegression: "name": "nws", "enabled": True, "cadence_s": 60, - "settings": {"contact_email": "old@example.com"}, # dict, as asyncpg returns + "settings": {"contact_email": "old@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}}, # dict, as asyncpg returns "paused_at": None, "updated_at": None, } @@ -471,6 +505,10 @@ class TestAdaptersJsonbRegression: mock_form.get.side_effect = lambda k, d="": { "cadence_s": "120", "contact_email": "new@example.com", + "region_north": "49.0", + "region_south": "24.0", + "region_east": "-66.0", + "region_west": "-125.0", }.get(k, d) mock_form.getlist.return_value = [] mock_form.__contains__ = lambda self, k: k == "enabled" @@ -481,7 +519,7 @@ class TestAdaptersJsonbRegression: "name": "nws", "enabled": True, "cadence_s": 60, - "settings": {"contact_email": "old@example.com"}, # dict + "settings": {"contact_email": "old@example.com", "region": {"north": 49, "south": 24, "east": -66, "west": -125}}, # dict "paused_at": None, "updated_at": None, } diff --git a/tests/test_region_picker.py b/tests/test_region_picker.py new file mode 100644 index 0000000..f5a8816 --- /dev/null +++ b/tests/test_region_picker.py @@ -0,0 +1,372 @@ +"""Tests for region picker functionality.""" + +import json +import os +from unittest.mock import MagicMock, AsyncMock, patch + +import pytest + +os.environ.setdefault("CENTRAL_DB_DSN", "postgresql://test:test@localhost/test") +os.environ.setdefault("CENTRAL_CSRF_SECRET", "testsecret12345678901234567890ab") +os.environ.setdefault("CENTRAL_NATS_URL", "nats://localhost:4222") + + +class TestRegionPickerInTemplate: + """Test region picker is included in adapter edit page.""" + + @pytest.mark.asyncio + async def test_get_adapters_firms_includes_map_div(self): + """GET /adapters/firms includes the map div with tile URL.""" + from central.gui.routes import adapters_edit_form + + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="testop") + + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + { # Adapter row + "name": "firms", + "enabled": True, + "cadence_s": 300, + "settings": { + "api_key_alias": "firms", + "satellites": ["VIIRS_SNPP_NRT"], + "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5} + }, + "paused_at": None, + "updated_at": None, + }, + { # System settings row + "map_tile_url": "https://tile.example.com/{z}/{x}/{y}.png", + "map_attribution": "Test Attribution", + }, + ] + mock_conn.fetch.return_value = [{"alias": "firms"}] + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_templates = MagicMock() + mock_response = MagicMock() + mock_templates.TemplateResponse.return_value = mock_response + + mock_csrf = MagicMock() + mock_csrf.generate_csrf_tokens.return_value = ("token", "signed") + mock_csrf.set_csrf_cookie = MagicMock() + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + result = await adapters_edit_form(mock_request, "firms", mock_csrf) + + call_args = mock_templates.TemplateResponse.call_args + context = call_args.kwargs.get("context", call_args[1].get("context")) + + # Should include tile URL from config.system + assert context["tile_url"] == "https://tile.example.com/{z}/{x}/{y}.png" + assert context["tile_attribution"] == "Test Attribution" + + +class TestRegionValidation: + """Test region coordinate validation in POST handler.""" + + @pytest.mark.asyncio + async def test_valid_region_updates_settings(self): + """POST with valid bbox updates settings.region.""" + from central.gui.routes import adapters_edit_submit + + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="testop") + + mock_form = MagicMock() + mock_form.get.side_effect = lambda k, d="": { + "cadence_s": "300", + "api_key_alias": "firms", + "region_north": "45.0", + "region_south": "35.0", + "region_east": "-100.0", + "region_west": "-120.0", + }.get(k, d) + mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"] + mock_form.__contains__ = lambda self, k: k == "enabled" + mock_request.form = AsyncMock(return_value=mock_form) + + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + { # Adapter row + "name": "firms", + "enabled": True, + "cadence_s": 300, + "settings": {"api_key_alias": "firms", "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}}, + "paused_at": None, + "updated_at": None, + }, + {"id": 1}, # api_key exists check + ] + mock_conn.execute = AsyncMock() + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_csrf = MagicMock() + mock_csrf.validate_csrf = AsyncMock() + + captured_settings = {} + + async def capture_execute(query, *args): + if "UPDATE config.adapters" in query: + captured_settings["settings"] = args[2] + + mock_conn.execute.side_effect = capture_execute + + with patch("central.gui.routes.get_pool", return_value=mock_pool): + with patch("central.gui.routes.write_audit", new_callable=AsyncMock): + result = await adapters_edit_submit(mock_request, "firms", mock_csrf) + + assert result.status_code == 302 + assert captured_settings["settings"]["region"]["north"] == 45.0 + assert captured_settings["settings"]["region"]["south"] == 35.0 + assert captured_settings["settings"]["region"]["east"] == -100.0 + assert captured_settings["settings"]["region"]["west"] == -120.0 + + @pytest.mark.asyncio + async def test_north_less_than_south_shows_error(self): + """POST with north <= south shows validation error.""" + from central.gui.routes import adapters_edit_submit + + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="testop") + + mock_form = MagicMock() + mock_form.get.side_effect = lambda k, d="": { + "cadence_s": "300", + "api_key_alias": "firms", + "region_north": "30.0", # Less than south! + "region_south": "35.0", + "region_east": "-100.0", + "region_west": "-120.0", + }.get(k, d) + mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"] + mock_form.__contains__ = lambda self, k: k == "enabled" + mock_request.form = AsyncMock(return_value=mock_form) + + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + { + "name": "firms", + "enabled": True, + "cadence_s": 300, + "settings": {"api_key_alias": "firms", "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}}, + "paused_at": None, + "updated_at": None, + }, + {"id": 1}, # api_key exists + {"map_tile_url": None, "map_attribution": None}, # system settings for re-render + ] + mock_conn.fetch.return_value = [{"alias": "firms"}] + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_templates = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_templates.TemplateResponse.return_value = mock_response + + mock_csrf = MagicMock() + mock_csrf.validate_csrf = AsyncMock() + mock_csrf.generate_csrf_tokens.return_value = ("token", "signed") + mock_csrf.set_csrf_cookie = MagicMock() + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + result = await adapters_edit_submit(mock_request, "firms", mock_csrf) + + call_args = mock_templates.TemplateResponse.call_args + context = call_args.kwargs.get("context", call_args[1].get("context")) + assert "region" in context["errors"] + assert "latitude" in context["errors"]["region"].lower() + + @pytest.mark.asyncio + async def test_west_greater_than_east_shows_error(self): + """POST with west >= east shows validation error.""" + from central.gui.routes import adapters_edit_submit + + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="testop") + + mock_form = MagicMock() + mock_form.get.side_effect = lambda k, d="": { + "cadence_s": "300", + "api_key_alias": "firms", + "region_north": "45.0", + "region_south": "35.0", + "region_east": "-130.0", # Less than west! + "region_west": "-120.0", + }.get(k, d) + mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"] + mock_form.__contains__ = lambda self, k: k == "enabled" + mock_request.form = AsyncMock(return_value=mock_form) + + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + { + "name": "firms", + "enabled": True, + "cadence_s": 300, + "settings": {"api_key_alias": "firms", "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}}, + "paused_at": None, + "updated_at": None, + }, + {"id": 1}, + {"map_tile_url": None, "map_attribution": None}, + ] + mock_conn.fetch.return_value = [{"alias": "firms"}] + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_templates = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_templates.TemplateResponse.return_value = mock_response + + mock_csrf = MagicMock() + mock_csrf.validate_csrf = AsyncMock() + mock_csrf.generate_csrf_tokens.return_value = ("token", "signed") + mock_csrf.set_csrf_cookie = MagicMock() + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + result = await adapters_edit_submit(mock_request, "firms", mock_csrf) + + call_args = mock_templates.TemplateResponse.call_args + context = call_args.kwargs.get("context", call_args[1].get("context")) + assert "region" in context["errors"] + assert "longitude" in context["errors"]["region"].lower() + + @pytest.mark.asyncio + async def test_latitude_out_of_bounds_shows_error(self): + """POST with lat > 90 shows validation error.""" + from central.gui.routes import adapters_edit_submit + + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="testop") + + mock_form = MagicMock() + mock_form.get.side_effect = lambda k, d="": { + "cadence_s": "300", + "api_key_alias": "firms", + "region_north": "95.0", # > 90! + "region_south": "35.0", + "region_east": "-100.0", + "region_west": "-120.0", + }.get(k, d) + mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"] + mock_form.__contains__ = lambda self, k: k == "enabled" + mock_request.form = AsyncMock(return_value=mock_form) + + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + { + "name": "firms", + "enabled": True, + "cadence_s": 300, + "settings": {"api_key_alias": "firms", "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5}}, + "paused_at": None, + "updated_at": None, + }, + {"id": 1}, + {"map_tile_url": None, "map_attribution": None}, + ] + mock_conn.fetch.return_value = [{"alias": "firms"}] + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_templates = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_templates.TemplateResponse.return_value = mock_response + + mock_csrf = MagicMock() + mock_csrf.validate_csrf = AsyncMock() + mock_csrf.generate_csrf_tokens.return_value = ("token", "signed") + mock_csrf.set_csrf_cookie = MagicMock() + + with patch("central.gui.routes._get_templates", return_value=mock_templates): + with patch("central.gui.routes.get_pool", return_value=mock_pool): + result = await adapters_edit_submit(mock_request, "firms", mock_csrf) + + call_args = mock_templates.TemplateResponse.call_args + context = call_args.kwargs.get("context", call_args[1].get("context")) + assert "region" in context["errors"] + + +class TestRegionAuditLog: + """Test region changes are captured in audit log.""" + + @pytest.mark.asyncio + async def test_audit_captures_region_change(self): + """Audit log captures region change in before/after settings.""" + from central.gui.routes import adapters_edit_submit + + mock_request = MagicMock() + mock_request.state.operator = MagicMock(id=1, username="testop") + + mock_form = MagicMock() + mock_form.get.side_effect = lambda k, d="": { + "cadence_s": "300", + "api_key_alias": "firms", + "region_north": "45.0", + "region_south": "35.0", + "region_east": "-100.0", + "region_west": "-120.0", + }.get(k, d) + mock_form.getlist.return_value = ["VIIRS_SNPP_NRT"] + mock_form.__contains__ = lambda self, k: k == "enabled" + mock_request.form = AsyncMock(return_value=mock_form) + + mock_conn = AsyncMock() + mock_conn.fetchrow.side_effect = [ + { + "name": "firms", + "enabled": True, + "cadence_s": 300, + "settings": { + "api_key_alias": "firms", + "region": {"north": 49.5, "south": 31.0, "east": -102.0, "west": -124.5} + }, + "paused_at": None, + "updated_at": None, + }, + {"id": 1}, + ] + mock_conn.execute = AsyncMock() + + mock_pool = MagicMock() + mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn) + mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + + mock_csrf = MagicMock() + mock_csrf.validate_csrf = AsyncMock() + + captured_audit = {} + + async def capture_audit(conn, action, operator_id=None, target=None, before=None, after=None): + captured_audit["before"] = before + captured_audit["after"] = after + + with patch("central.gui.routes.get_pool", return_value=mock_pool): + with patch("central.gui.routes.write_audit", side_effect=capture_audit): + result = await adapters_edit_submit(mock_request, "firms", mock_csrf) + + # Before should have old region + assert captured_audit["before"]["settings"]["region"]["north"] == 49.5 + # After should have new region + assert captured_audit["after"]["settings"]["region"]["north"] == 45.0 + assert captured_audit["after"]["settings"]["region"]["south"] == 35.0