gendesign/backend/tests/services/test_2464_three_small_leaks.py
bot-backend f227768a53
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m17s
CI / backend-tests (pull_request) Successful in 17m20s
fix(ptica): три места, где код делал не то, что говорил (#2464)
1. `load_water_reserves_from_docx` собирал `result` с ключом `period`,
   печатал ПОЛНЫЙ словарь в лог, а возвращал
   `{k: v for … if isinstance(v, int)}` — период это строка или None,
   поэтому выбрасывался всегда. По логам казалось, что период отдаётся;
   вызывающий не получал его ни разу.

   Фильтр стоял ради аннотации `dict[str, int]` и ничего не защищал:
   соседняя ветка `load_water_reserves` кладёт в тот же словарь
   `{"error": str(...)}`, а единственный потребитель — задача
   `sync_water_reserves` — результат логирует и возвращает как есть.
   Период полезен: без него «загружено 42 записи» не отличить от
   прошлогодних. Аннотация исправлена, иначе следующий проход mypy вернул
   бы фильтр обратно — на это поставлен отдельный тест.

2. `get_sqlite_info` — TOCTOU: `p.exists()`, затем незащищённый
   `p.stat()`. try/except покрывал только `sqlite3.connect` ниже, поэтому
   OSError из stat улетал наружу и превращал диагностическую функцию в
   источник отказа. Файл между проверками реально исчезает — его
   переписывает выгрузка Объектива. Теперь отдаём то, что успели узнать,
   с ключом `stat_error`.

3. `place_program` — предупреждение «участок мал» печатало КАТАЛОЖНЫЕ
   `house.footprint_*`, хотя ставили по `fp_w`/`fp_d`. При
   переопределённом в программе габарите сообщение называло размер,
   которым никто не пытался ставить, и уводило от причины.

Двусторонне: против origin/main четыре теста красные с конкретными
значениями («период выброшен из ответа: {'records': 1, 'inserted': 1,
'updated': 0}», «аннотация всё ещё требует только int: dict[str, int]»).

Контроли зелёные с обеих сторон: отсутствующий файл по-прежнему даёт
exists=False без ошибки; `fp_w`/`fp_d` — действительно те размеры,
которыми ставят (иначе первый тест сверял бы имена, а не смысл).

pytest backend/tests/services/ — 3207 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:59:02 +05:00

129 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Три места, где код делал не то, что говорил (#2464).
1. `load_water_reserves_from_docx` (vodokanal): собирал `result` с ключом `period`,
печатал полный словарь в лог, а возвращал `{k: v for … if isinstance(v, int)}` —
период это строка или None, поэтому он выбрасывался ВСЕГДА. По логам казалось,
что период отдаётся; вызывающий не получал его ни разу.
2. `get_sqlite_info` (objective_etl): TOCTOU — `p.exists()`, затем незащищённый
`p.stat()`. try/except покрывал только `sqlite3.connect` ниже, поэтому OSError
из stat улетал наружу и превращал диагностическую функцию в источник отказа.
3. `place_program` (placement): предупреждение «участок мал» печатало КАТАЛОЖНЫЕ
`house.footprint_*`, хотя ставили по `fp_w`/`fp_d`. Если элемент программы
переопределил габарит, сообщение называло размер, которым никто не пытался
ставить, — диагностика уводила от причины.
"""
from __future__ import annotations
import inspect
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ── 1. period больше не выбрасывается ────────────────────────────────────────
def test_water_result_keeps_period() -> None:
"""Головной: период обязан доехать до вызывающего, раз он попал в лог.
На origin/main фильтр `isinstance(v, int)` выбрасывает его всегда.
"""
from app.services.site_finder import vodokanal_reserve_loader as mod
with (
patch.object(mod, "parse_docx_table_rows", lambda _x: [["шапка"]]),
patch.object(mod, "extract_water_rows", lambda _m: [{"name": "ЦСВ-1"}]),
patch.object(mod, "_dedupe_names", lambda r: r),
patch.object(mod, "infer_period", lambda _u: "III кв. 2025"),
patch.object(mod, "_upsert_water_rows", lambda *a, **k: {"inserted": 1, "updated": 0}),
patch.object(mod.zipfile, "ZipFile", MagicMock()),
):
res = mod.load_water_reserves_from_docx(MagicMock(), "supply", b"", "http://x")
assert (
res.get("period") == "III кв. 2025"
), f"период выброшен из ответа: {res} — по логам он есть, у вызывающего нет"
assert res.get("records") == 1 and res.get("inserted") == 1, res
def test_water_return_annotation_allows_non_int() -> None:
"""Контроль: фильтр стоял ради аннотации `dict[str, int]` — она тоже исправлена.
Иначе следующий проход mypy вернул бы фильтр обратно.
"""
from app.services.site_finder.vodokanal_reserve_loader import load_water_reserves_from_docx
ann = inspect.signature(load_water_reserves_from_docx).return_annotation
assert "int]" not in str(ann), f"аннотация всё ещё требует только int: {ann}"
# ── 2. TOCTOU в get_sqlite_info ──────────────────────────────────────────────
def test_sqlite_info_survives_file_vanishing_between_exists_and_stat() -> None:
"""Головной: файл исчез между exists() и stat() — функция не падает.
На origin/main OSError улетает наружу: try/except покрывает только connect.
"""
from app.services import objective_etl as mod
настоящий_stat = Path.stat
def _stat(self, *a, **k):
if str(self).endswith("исчезающий.sqlite"):
raise FileNotFoundError(2, "No such file or directory")
return настоящий_stat(self, *a, **k)
with (
patch.object(Path, "exists", lambda self: True),
patch.object(Path, "stat", _stat),
):
info = mod.get_sqlite_info("/tmp/исчезающий.sqlite")
assert info["exists"] is True
assert "stat_error" in info, f"ошибка stat не отражена в ответе: {info}"
assert "size_bytes" not in info, "размер выдуман при отсутствующем файле"
def test_sqlite_info_missing_file_unchanged() -> None:
"""Контроль: отсутствующий файл по-прежнему даёт exists=False без ошибок."""
from app.services.objective_etl import get_sqlite_info
info = get_sqlite_info("/tmp/такого-файла-нет-2464.sqlite")
assert info["exists"] is False
assert "stat_error" not in info, info
# ── 3. предупреждение placement называет фактические габариты ────────────────
def test_placement_warning_uses_actual_footprint_not_catalog() -> None:
"""Головной: в сообщении должны стоять fp_w/fp_d, а не house.footprint_*.
На origin/main при переопределённом габарите печатается каталожный размер.
"""
from app.services.generative import placement as mod
src = inspect.getsource(mod.place_program)
хвост = src[src.index("участок мал") :]
assert (
"fp_w," in хвост and "fp_d," in хвост
), f"в предупреждении не фактические габариты:\n{хвост[:320]}"
assert (
"house.footprint_w_m," not in хвост and "house.footprint_d_m," not in хвост
), f"в предупреждении остался каталожный размер:\n{хвост[:320]}"
@pytest.mark.parametrize("имя", ["fp_w", "fp_d"])
def test_placement_uses_those_names_for_placement(имя: str) -> None:
"""Контроль: fp_w/fp_d — это действительно те размеры, которыми ставят.
Без него тест выше проверял бы совпадение имён, а не смысл.
"""
from app.services.generative import placement as mod
src = inspect.getsource(mod.place_program)
assert "_centered_footprint(cell.cx, cell.cy, fp_w, fp_d)" in src, src[:200]
assert имя in src