diff --git a/backend/app/workers/tasks/nspd_sync.py b/backend/app/workers/tasks/nspd_sync.py index 2743a630..3c377919 100644 --- a/backend/app/workers/tasks/nspd_sync.py +++ b/backend/app/workers/tasks/nspd_sync.py @@ -125,11 +125,17 @@ _UPSERT_SQL = text( harvest_error, region_code ) VALUES ( :quarter_cad, - CASE WHEN :geom_json IS NULL THEN NULL - ELSE ST_Multi(ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom_json), 3857), 4326)) + CASE WHEN CAST(:geom_json AS text) IS NULL THEN NULL + ELSE ST_Multi(ST_Transform( + ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom_json AS text)), 3857), 4326)) END, - CASE WHEN :bbox_xmin IS NULL THEN NULL - ELSE ST_MakeEnvelope(:bbox_xmin, :bbox_ymin, :bbox_xmax, :bbox_ymax, 3857) + CASE WHEN CAST(:bbox_xmin AS double precision) IS NULL THEN NULL + ELSE ST_MakeEnvelope( + CAST(:bbox_xmin AS double precision), + CAST(:bbox_ymin AS double precision), + CAST(:bbox_xmax AS double precision), + CAST(:bbox_ymax AS double precision), + 3857) END, :parcels_count, :buildings_count, :territorial_zones_count, :red_lines_count, :engineering_count, :zouit_count, :risks_count, :total_features, diff --git a/backend/tests/test_nspd_sync.py b/backend/tests/test_nspd_sync.py index 8b077cf6..c30b4496 100644 --- a/backend/tests/test_nspd_sync.py +++ b/backend/tests/test_nspd_sync.py @@ -25,6 +25,7 @@ from app.workers.tasks.nspd_sync import ( _build_features_json, _build_risks_count, _build_zouit_count, + _upsert_dump, harvest_quarter, harvest_stale_quarters, ) @@ -71,6 +72,7 @@ def _make_dump( engineering_structures=[_make_feat("e1")], zouit=zouit, risks=risks, + opportunity={}, layers_fetched=( "search", "parcels", @@ -204,6 +206,7 @@ def test_harvest_quarter_empty_quarter( engineering_structures=[], zouit={"okn": [], "engineering": [], "natural": [], "protected": [], "other": []}, risks={}, + opportunity={}, layers_fetched=("search",), bbox_3857=None, fetched_at_utc="2026-05-12T03:00:00+00:00", @@ -365,6 +368,7 @@ def test_features_json_geometry_preserved() -> None: engineering_structures=[], zouit={}, risks={}, + opportunity={}, layers_fetched=("search", "parcels"), bbox_3857=(100.0, 200.0, 300.0, 400.0), fetched_at_utc="2026-05-12T00:00:00+00:00", @@ -375,3 +379,70 @@ def test_features_json_geometry_preserved() -> None: assert result[0]["layer"] == "parcels" assert result[0]["feature_id"] == "p1" assert result[0]["properties"] == {"k": "v"} + + +# ── _upsert_dump CAST regression tests (#244) ──────────────────────────────── + + +@patch("app.workers.tasks.nspd_sync.SessionLocal") +def test_upsert_dump_null_geom_executes_without_error(mock_session_cls: MagicMock) -> None: + """Regression #244: _upsert_dump с geom_json=None не падает на AmbiguousParameter. + + psycopg3 не может вывести тип голого параметра $N внутри CASE WHEN ... IS NULL. + Fix: CAST(:geom_json AS text) IS NULL даёт явный тип — нет AmbiguousParameter. + """ + mock_db = MagicMock() + mock_session_cls.return_value = mock_db + + # dump=None → error-only path: все geo-параметры None, должен execute без ошибок + _upsert_dump( + quarter_cad="66:41:0204016", + region_code=66, + dump=None, + features_json=None, + duration_ms=100, + harvest_error="TestError: simulated", + ) + + mock_db.execute.assert_called_once() + mock_db.commit.assert_called_once() + + +@patch("app.workers.tasks.nspd_sync.SessionLocal") +def test_upsert_dump_with_geom_executes_without_error(mock_session_cls: MagicMock) -> None: + """Regression #244: _upsert_dump с реальным geom_json и bbox не падает. + + CAST(:geom_json AS text) передаёт строку, CAST(:bbox_xmin AS double precision) — + число. Оба branch CASE WHEN типизированы корректно. + """ + mock_db = MagicMock() + mock_session_cls.return_value = mock_db + + geom = { + "type": "Polygon", + "coordinates": [ + [[100.0, 200.0], [300.0, 200.0], [300.0, 400.0], [100.0, 400.0], [100.0, 200.0]] + ], + } + dump = _make_dump( + quarter_cad="66:41:0204016", + quarter_feat=_make_feat_with_geom("q1", geom), + bbox=(100.0, 200.0, 300.0, 400.0), + ) + + _upsert_dump( + quarter_cad="66:41:0204016", + region_code=66, + dump=dump, + features_json=_build_features_json(dump), + duration_ms=500, + harvest_error=None, + ) + + mock_db.execute.assert_called_once() + # Verify params contain properly-typed values (not None for geo fields) + call_params = mock_db.execute.call_args[0][1] + assert call_params["geom_json"] is not None # JSON string + assert call_params["bbox_xmin"] == 100.0 + assert call_params["bbox_ymax"] == 400.0 + mock_db.commit.assert_called_once()