From d3d21b92f5d65047cc106548e18f9d80e5516cf5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 14:33:31 +0500 Subject: [PATCH] =?UTF-8?q?fix(ptica):=20scope=3Dmanual=5Flist=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=91=D1=82=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B7=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B8=D0=B7=20=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=82=D1=8B=20(#2464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверка непустоты стояла ДО очистки: if not quarters: # [" "] — непустой, проходит raise HTTPException(400, ...) return [q.strip() for q in quarters if q.strip()] # → [] Список из одних пробелов проходил как непустой, а после strip оставался пустым. Дальше по коду это молча создавало job с нулём кварталов, ставило его в очередь Celery и возвращало targets_total=0 с estimate_minutes=0.0 — пустышку, неотличимую в списке заданий от настоящей. Правка: сначала чистим, потом проверяем. Прежние отказы (None, []) сохраняются. Тест проверяет ПОВЕДЕНИЕ: на пробельном вводе функция обязана отказать. Против origin/main три случая падают с «DID NOT RAISE» — то есть по отсутствию отказа, а не по отсутствию символа. [" "], ["", " ", "\t"], ["\n"] → DID NOT RAISE → падает None и [] — контроль, зелёный с обеих сторон годный список проходит и чистится — контроль, зелёный с обеих сторон смешанный список сохраняет живые — контроль, зелёный с обеих сторон Третий и четвёртый контроли не для симметрии: они ловят «починку», которая отказывала бы на любом вводе с пробелами или отбрасывала бы смешанный список целиком. Прогоны: tests/api/v1 -k cadastre — 7 passed rc=0. Co-Authored-By: Claude Opus 5 --- backend/app/api/v1/admin_cadastre.py | 10 ++- .../v1/test_2464_manual_list_validation.py | 68 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 backend/tests/api/v1/test_2464_manual_list_validation.py diff --git a/backend/app/api/v1/admin_cadastre.py b/backend/app/api/v1/admin_cadastre.py index b24e794a..f6965d1c 100644 --- a/backend/app/api/v1/admin_cadastre.py +++ b/backend/app/api/v1/admin_cadastre.py @@ -81,12 +81,18 @@ def _resolve_quarters( ) -> list[str]: """Собрать список кварталов согласно scope.""" if scope == "manual_list": - if not quarters: + # Сначала чистим, потом проверяем (#2464). Раньше порядок был обратным, и + # список из одних пробелов проходил проверку `not quarters` как непустой, + # а после strip превращался в []. Дальше по коду это молча создавало job + # с нулём кварталов, ставило его в очередь и возвращало targets_total=0 — + # пустышку, неотличимую в списке заданий от настоящей. + cleaned = [q.strip() for q in (quarters or []) if q.strip()] + if not cleaned: raise HTTPException( status_code=400, detail="scope=manual_list требует непустой список quarters", ) - return [q.strip() for q in quarters if q.strip()] + return cleaned cap = limit or (PILOT_LIMIT if scope == "pilot" else 100000) diff --git a/backend/tests/api/v1/test_2464_manual_list_validation.py b/backend/tests/api/v1/test_2464_manual_list_validation.py new file mode 100644 index 00000000..43d0f21a --- /dev/null +++ b/backend/tests/api/v1/test_2464_manual_list_validation.py @@ -0,0 +1,68 @@ +"""scope=manual_list не должен создавать задание из пустоты (#2464). + +Проверка непустоты стояла ДО очистки: список из одних пробелов проходил как +непустой, а после `strip` оставался пустым. Дальше по коду создавался job с нулём +кварталов, ставился в очередь и возвращался `targets_total=0` — пустышка, +неотличимая в списке заданий от настоящей. + +Проверяется ПОВЕДЕНИЕ функции резолва: на пробельном вводе она обязана отказать, +а не вернуть пустой список. На origin/main тесты падают тем, что отказа НЕТ. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import HTTPException + + +def _resolve(quarters): + from app.api.v1.admin_cadastre import _resolve_quarters + + # scope=manual_list не ходит в БД — db здесь не используется. + return _resolve_quarters(db=None, scope="manual_list", quarters=quarters, limit=None) + + +@pytest.mark.parametrize( + "quarters", + [ + pytest.param([" "], id="один-пробельный"), + pytest.param(["", " ", "\t"], id="несколько-пустых"), + pytest.param(["\n"], id="перевод-строки"), + ], +) +def test_whitespace_only_list_is_rejected(quarters) -> None: + """Список,из которого после очистки ничего не остаётся, — это пустой список.""" + with pytest.raises(HTTPException) as exc: + _resolve(quarters) + assert exc.value.status_code == 400 + + +@pytest.mark.parametrize( + "quarters", + [pytest.param(None, id="none"), pytest.param([], id="пустой-список")], +) +def test_empty_input_still_rejected(quarters) -> None: + """Контроль: прежние отказы никуда не делись.""" + with pytest.raises(HTTPException) as exc: + _resolve(quarters) + assert exc.value.status_code == 400 + + +def test_valid_list_passes_and_is_trimmed() -> None: + """Контроль: годный список проходит и по-прежнему очищается от пробелов. + + Ловит «починку», которая отказывала бы на любом вводе с пробелами. + """ + assert _resolve([" 66:41:0702017 ", "66:41:0106001"]) == [ + "66:41:0702017", + "66:41:0106001", + ] + + +def test_partially_blank_list_keeps_real_entries() -> None: + """Контроль: смешанный список не отбрасывается целиком — остаются настоящие.""" + assert _resolve([" ", "66:41:0702017", ""]) == ["66:41:0702017"] -- 2.45.3