fix(#244): harvest_quarter — CAST CASE WHEN params (psycopg3 AmbiguousParameter)

psycopg3 could not determine the type of bare bind params inside CASE WHEN
... IS NULL expressions in _UPSERT_SQL. Wrap geom_json as CAST(:geom_json AS text)
and bbox coords as CAST(:bbox_xmin AS double precision) so Postgres has an explicit
type hint in both branches.

Also fixes pre-existing test breakage: QuarterDump gained required field
`opportunity` in a prior PR; added opportunity={} to all 3 inline QuarterDump
constructors in test_nspd_sync.py. Adds 2 regression tests for _upsert_dump
(null-geom path and real-geom path) to catch re-introduction.
This commit is contained in:
lekss361 2026-05-17 08:45:13 +03:00
parent 7d0bc4bb6b
commit aec45a55cb
2 changed files with 81 additions and 4 deletions

View file

@ -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,

View file

@ -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()