diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py
index 43c67e85..a189c916 100644
--- a/backend/app/api/v1/parcels.py
+++ b/backend/app/api/v1/parcels.py
@@ -216,6 +216,19 @@ def _confidence_label(c: float) -> str:
return "low"
+_ROMAN_QUARTERS = ("I", "II", "III", "IV")
+
+
+def _deals_quarter_label(d: _dt.date) -> str:
+ """Дата сделок Росреестра → «I квартал 2026».
+
+ Росреестр отдаёт сделки ПОКВАРТАЛЬНО без точных дат: max(deal_date) = начало
+ квартала поставки (2026-01-01 = I квартал 2026), а НЕ «источник устарел».
+ Месяц 1-3→I, 4-6→II, 7-9→III, 10-12→IV (римские).
+ """
+ return f"{_ROMAN_QUARTERS[(d.month - 1) // 3]} квартал {d.year}"
+
+
# Человеко-читаемые имена категорий для verbal breakdown (X1).
_POI_CATEGORY_RU: dict[str, str] = {
"school": "Школа",
@@ -2873,12 +2886,16 @@ def analyze_parcel(
_deals_stale = (
recent_n_val == 0 and max_period is not None and max_period < stale_cutoff
)
- # Разные причины отсутствия сделок → честная микрокопия: устаревание
- # источника vs просто нет сделок рядом (источник свеж). Обе показывают
+ # Честная микрокопия. Росреестр отдаёт сделки ПОКВАРТАЛЬНО (max(deal_date)
+ # = начало квартала поставки) — это НЕ «источник устарел». Для stale-ветки
+ # называем квартал поставки; иначе просто нет сделок рядом. Обе показывают
# предложение как основной контент.
_offer_only_label = (
- "Сделки устарели — показываем динамику цен предложения"
- if _deals_stale
+ (
+ f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
+ "(поставка поквартальная); показываем динамику цен предложения"
+ )
+ if _deals_stale and max_period is not None
else "Нет сделок рядом — показываем динамику цен предложения"
)
market_trend = {
@@ -2891,13 +2908,17 @@ def analyze_parcel(
"offer_trend": offer_block,
}
elif recent_n_val == 0 and max_period is not None and max_period < stale_cutoff:
- # Recent-окно пусто И latest deal старше 3 мес → источник устарел (не «нет рядом»).
- # #2178: сюда попадаем ТОЛЬКО когда и offer-данных нет (offer_block is None) —
- # прежний source_stale остаётся честным «нечего показать».
+ # Recent-окно пусто И latest deal старше 3 мес. Росреестр отдаёт сделки
+ # ПОКВАРТАЛЬНО (max(deal_date) = начало квартала поставки) — честно
+ # называем квартал поставки, НЕ «источник устарел». #2178: сюда попадаем
+ # ТОЛЬКО когда и offer-данных нет (offer_block is None).
market_trend = {
"status": "source_stale",
"as_of_date": max_period.isoformat(),
- "label": (f"Источник сделок устарел (данные до {max_period.strftime('%m.%Y')})"),
+ "label": (
+ f"Сделки Росреестра — за {_deals_quarter_label(max_period)} "
+ "(поставка поквартальная); тренд по сделкам не показываем"
+ ),
"recent_deals_count": 0,
}
else:
diff --git a/backend/app/services/site_finder/gazprom_grs_loader.py b/backend/app/services/site_finder/gazprom_grs_loader.py
index 1915fbc5..034692b3 100644
--- a/backend/app/services/site_finder/gazprom_grs_loader.py
+++ b/backend/app/services/site_finder/gazprom_grs_loader.py
@@ -94,7 +94,10 @@ _SVERDLOVSK_TOKEN = "свердлов"
# НЕ выход (это доп. нумерация станции), остаётся в имени. В живых данных фрагмент
# стоит НЕ только в конце, но и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)»)
# → search по всему имени (не $-якорь), фрагмент вырезаем, остаток склеиваем. #2119 B1.
-_OUTPUT_SUFFIX_RE = re.compile(r"\((выход\s*№?\s*\d+)\)", re.IGNORECASE)
+# Группы: 1=«выход», 2=номер (для канонизации «выход №N» без разнобоя пробелов вокруг №,
+# иначе «выход № 1» и «выход №1» породили бы РАЗНЫЕ output_name → дубли по
+# ON CONFLICT (grs_name_norm, output_name)).
+_OUTPUT_SUFFIX_RE = re.compile(r"\(\s*(выход)\s*№?\s*(\d+)\s*\)", re.IGNORECASE)
# FULL-строка: idx имени=2, проектная=3, загрузка=4, объём ТУ=5, свободная=6,
# свободная %=7, уведомление(основание/срок/параметры)=8,9,10.
@@ -168,12 +171,16 @@ def _split_grs_name(raw_name: str) -> tuple[str, str | None]:
«ГРС Арти (выход №2)» → («ГРС Арти», «выход №2»); фрагмент «(выход №N)» бывает
и В СЕРЕДИНЕ имени («ГРС-1 Свердловск (выход №1) (5)» → («ГРС-1 Свердловск (5)»,
«выход №1»)) — вырезаем его, остаток склеиваем и нормализуем двойные пробелы.
+ output_name канонизируется в «выход №N» (нижний регистр, ровно один пробел после
+ «выход», сразу «№N» без пробела) — разнобой пробелов вокруг «№» иначе дал бы
+ разные output_name и дубли по ON CONFLICT (grs_name_norm, output_name).
«ГРС Арамиль (5)» → без изменений (цифра в скобках — НЕ выход, часть имени).
"""
m = _OUTPUT_SUFFIX_RE.search(raw_name)
if not m:
return raw_name, None
- output = _MULTISPACE_RE.sub(" ", m.group(1)).strip()
+ # Канонический output_name: «выход №N» (число из группы 2, «№» приклеено к цифре).
+ output = f"{m.group(1).lower()} №{m.group(2)}"
# Вырезаем фрагмент из ЛЮБОГО места имени и склеиваем остаток (нормализуя
# двойные пробелы, оставшиеся на месте выреза в середине).
grs_name = raw_name[: m.start()] + raw_name[m.end() :]
diff --git a/backend/tests/test_gas_grs_loader.py b/backend/tests/test_gas_grs_loader.py
index 622343cc..54687e72 100644
--- a/backend/tests/test_gas_grs_loader.py
+++ b/backend/tests/test_gas_grs_loader.py
@@ -204,6 +204,36 @@ def test_split_grs_name_no_output() -> None:
assert gg._split_grs_name("ГРС Кольцово") == ("ГРС Кольцово", None)
+def test_split_grs_name_output_before_station_digit_real_rows() -> None:
+ """B1-хвост: РЕАЛЬНЫЕ прод-строки «(выход №N) (M)» → output_name извлекается.
+
+ Данные ГТЕ (asof 2026-06-01, снято 2026-07-02): FULL-строки, где «(выход №N)»
+ стоит ПЕРЕД станционным «(M)». До search-фикса они оседали в grs_name с пустым
+ output_name (5 строк). Локим на точных прод-именах — не регрессировать.
+ """
+ cases = {
+ "ГРС-1 Свердловск (выход №1) (5)": ("ГРС-1 Свердловск (5)", "выход №1"),
+ "ГРС-1 Свердловск (выход №2) (5)": ("ГРС-1 Свердловск (5)", "выход №2"),
+ "ГРС-3 Екатеринбург (выход №1) (3)": ("ГРС-3 Екатеринбург (3)", "выход №1"),
+ "ГРС Реж (выход №1) (3)": ("ГРС Реж (3)", "выход №1"),
+ "ГРС Сухой Лог (выход №1) (4)": ("ГРС Сухой Лог (4)", "выход №1"),
+ }
+ for raw, expected in cases.items():
+ assert gg._split_grs_name(raw) == expected, raw
+
+
+def test_split_grs_name_output_canonicalized() -> None:
+ """output_name канонизируется в «выход №N» независимо от пробелов/регистра/«№».
+
+ «Выход № 1» / «выход 1» / «выход №1» → все дают «выход №1» (иначе разный
+ output_name → дубли по ON CONFLICT (grs_name_norm, output_name)).
+ """
+ for raw in ("ГРС Х (Выход № 1)", "ГРС Х (выход 1)", "ГРС Х (выход №1)"):
+ grs_name, output = gg._split_grs_name(raw)
+ assert output == "выход №1", raw
+ assert grs_name == "ГРС Х"
+
+
# ── _passes_region_filter ─────────────────────────────────────────────────────
diff --git a/frontend/src/components/site-finder/MarketTrendBlock.tsx b/frontend/src/components/site-finder/MarketTrendBlock.tsx
index ad84dfda..81f85d24 100644
--- a/frontend/src/components/site-finder/MarketTrendBlock.tsx
+++ b/frontend/src/components/site-finder/MarketTrendBlock.tsx
@@ -38,34 +38,22 @@ function fmtPrice(v: number): string {
}
/**
- * P2 (#1871): дата as_of_date (YYYY-MM-DD) → «месяц год» для caption о
- * устаревшем источнике. Возвращает null если дата отсутствует/некорректна.
+ * Росреестр отдаёт сделки ПОКВАРТАЛЬНО без точных дат: max(deal_date)=2026-01-01
+ * означает «поставка за I квартал 2026», а НЕ «источник встал в январе». Дата
+ * YYYY-MM-DD → «I квартал 2026» (римские I/II/III/IV по месяцу). null при
+ * отсутствии/некорректной дате. Парсим компоненты напрямую (не new Date() — TZ).
*/
-const RU_MONTHS = [
- "январь",
- "февраль",
- "март",
- "апрель",
- "май",
- "июнь",
- "июль",
- "август",
- "сентябрь",
- "октябрь",
- "ноябрь",
- "декабрь",
-];
+const ROMAN_QUARTERS = ["I", "II", "III", "IV"];
-function formatAsOfMonth(asOf: string | undefined): string | null {
+function formatDealsQuarter(asOf: string | undefined | null): string | null {
if (!asOf) return null;
- // Парсим YYYY-MM напрямую — НЕ через new Date() (UTC-полночь сдвинула бы
- // месяц назад в TZ западнее UTC на границе, напр. 2026-01-01 → «декабрь 2025»).
const m = /^(\d{4})-(\d{2})/.exec(asOf);
if (!m) return null;
const year = Number(m[1]);
- const monthIdx = Number(m[2]) - 1;
- if (monthIdx < 0 || monthIdx > 11) return null;
- return `${RU_MONTHS[monthIdx]} ${year}`;
+ const month = Number(m[2]);
+ if (month < 1 || month > 12) return null;
+ const quarter = ROMAN_QUARTERS[Math.floor((month - 1) / 3)];
+ return `${quarter} квартал ${year}`;
}
/**
@@ -196,13 +184,15 @@ function OfferOnlyCard({
const sourceLine =
sourceParts.join(" ") + (period != null ? ` · ${period}` : "");
- // Caveat — почему показываем предложение вместо сделок.
- const dealsMonth = formatAsOfMonth(dealsAsOfDate ?? undefined);
+ // Caveat — почему показываем предложение вместо сделок. Сделки Росреестра
+ // приходят ПОКВАРТАЛЬНО (max(deal_date) = начало квартала поставки) — это НЕ
+ // «источник устарел», а нормальный лаг квартальной выгрузки. Честная копия.
+ const dealsQuarter = formatDealsQuarter(dealsAsOfDate);
const caveat = dealsStale
- ? dealsMonth != null
- ? `Сделки Росреестра устарели (до ${dealsMonth}) — показана динамика цен предложения.`
- : "Сделки Росреестра устарели — показана динамика цен предложения."
- : "Сделок Росреестра рядом нет — показана динамика цен предложения.";
+ ? dealsQuarter != null
+ ? `Сделки Росреестра — за ${dealsQuarter} (поставка поквартальная); показана динамика цен предложения (Объектив).`
+ : "Сделки Росреестра поставляются поквартально; показана динамика цен предложения (Объектив)."
+ : "Сделок Росреестра рядом нет — показана динамика цен предложения (Объектив).";
return (
);
}
- // P2 (#1871): источник сделок устарел — caveat вместо фейкового тренда.
+ // #1871/quarterly: сделки Росреестра приходят поквартально (as_of_date = начало
+ // квартала поставки), НЕ «устарели». Показываем честную квартальную подпись
+ // вместо тренда (recent-окно пусто, offer-данных тоже нет). Бэк-label в приоритете.
if (status === "source_stale") {
- const asOfMonth = formatAsOfMonth(trend.as_of_date);
+ const quarter = formatDealsQuarter(trend.as_of_date);
const message =
trend.label ??
- (asOfMonth != null
- ? `Источник сделок устарел (данные до ${asOfMonth}).`
- : "Источник сделок устарел — тренд не показываем.");
+ (quarter != null
+ ? `Сделки Росреестра — за ${quarter} (поставка поквартальная); динамику цен предложения показать не удалось.`
+ : "Сделки Росреестра поставляются поквартально; тренд по сделкам не показываем.");
return ;
}
diff --git a/frontend/src/components/site-finder/__tests__/connection-capacity-format.test.ts b/frontend/src/components/site-finder/__tests__/connection-capacity-format.test.ts
new file mode 100644
index 00000000..d6ff88ab
--- /dev/null
+++ b/frontend/src/components/site-finder/__tests__/connection-capacity-format.test.ts
@@ -0,0 +1,45 @@
+/**
+ * Unit tests for §3 «Сети» number formatters (connection-capacity.ts).
+ *
+ * Focus: honest near-zero rendering of ГРС free capacity. При 1 знаке после
+ * запятой «0,044 тыс. м³/ч» округлялось бы в «0,0» — ГРС с крохотной, но
+ * НЕнулевой свободной мощностью выглядела бы закрытой. Для 0 < |v| < 0,05
+ * показываем 2 знака; настоящий 0 остаётся «0,0».
+ */
+import { describe, expect, it } from "vitest";
+
+import { formatThousandM3Hour } from "../connection-capacity";
+
+// toLocaleString("ru-RU") даёт запятую-десятичную и Unicode-минус (−) для
+// отрицательных — сверяем именно на этих формах.
+describe("formatThousandM3Hour — честность околонуля", () => {
+ it("крохотная положительная мощность не схлопывается в 0,0", () => {
+ expect(formatThousandM3Hour(0.044)).toBe("0,04 тыс. м³/ч");
+ expect(formatThousandM3Hour("0.044")).toBe("0,04 тыс. м³/ч");
+ });
+
+ it("крохотная отрицательная мощность (дефицит) сохраняет знак и не нулится", () => {
+ // toLocaleString даёт минус (ASCII «-» или Unicode «−» в зависимости от ICU
+ // рантайма) + 2 знака — не «0,0». Сверяем знак + значимую часть, не глиф.
+ const out = formatThousandM3Hour(-0.044);
+ expect(out).not.toBeNull();
+ expect(out).toMatch(/^[-−]0,04 тыс\. м³\/ч$/u);
+ });
+
+ it("настоящий ноль остаётся 0,0 (1 знак)", () => {
+ expect(formatThousandM3Hour(0)).toBe("0,0 тыс. м³/ч");
+ expect(formatThousandM3Hour("0")).toBe("0,0 тыс. м³/ч");
+ });
+
+ it("значения ≥ 0,05 — прежний 1 знак после запятой", () => {
+ expect(formatThousandM3Hour(1.205)).toBe("1,2 тыс. м³/ч");
+ expect(formatThousandM3Hour(0.05)).toBe("0,1 тыс. м³/ч");
+ expect(formatThousandM3Hour(42.2)).toBe("42,2 тыс. м³/ч");
+ });
+
+ it("null/undefined/нечисло → null (поле скрывается, не «0»)", () => {
+ expect(formatThousandM3Hour(null)).toBeNull();
+ expect(formatThousandM3Hour(undefined)).toBeNull();
+ expect(formatThousandM3Hour("н/д")).toBeNull();
+ });
+});
diff --git a/frontend/src/components/site-finder/connection-capacity.ts b/frontend/src/components/site-finder/connection-capacity.ts
index 4b587b0d..d0410252 100644
--- a/frontend/src/components/site-finder/connection-capacity.ts
+++ b/frontend/src/components/site-finder/connection-capacity.ts
@@ -132,14 +132,19 @@ export function formatMeters(
}
// тыс. м³/ч (свободная/проектная мощность ГРС). Приводит строку→число (Decimal→str).
+//
+// Честность околонуля: при 1 знаке «0,044» округлился бы в «0,0» и ГРС с
+// НЕнулевой (но крохотной) свободной мощностью выглядела бы закрытой. Для
+// 0 < |v| < 0,05 показываем 2 знака («0,04» / «−0,04»); настоящий 0 — «0,0».
export function formatThousandM3Hour(
v: number | string | null | undefined,
): string | null {
const n = toFiniteNumber(v);
if (n === null) return null;
+ const digits = n !== 0 && Math.abs(n) < 0.05 ? 2 : 1;
return `${n.toLocaleString("ru-RU", {
- minimumFractionDigits: 1,
- maximumFractionDigits: 1,
+ minimumFractionDigits: digits,
+ maximumFractionDigits: digits,
})} тыс. м³/ч`;
}
diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py
index 8b243d1b..ff45e927 100644
--- a/tradein-mvp/backend/app/api/v1/admin.py
+++ b/tradein-mvp/backend/app/api/v1/admin.py
@@ -11,6 +11,7 @@ import logging
import time
from typing import Annotated, Any, Literal
from urllib.parse import urlparse, urlunparse
+from uuid import uuid4
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
@@ -68,6 +69,22 @@ def _assert_allowed_url(url: str) -> None:
raise HTTPException(status_code=400, detail="host not allowed")
+def _safe_http_error(status_code: int, public_detail: str, log_context: str) -> HTTPException:
+ """Defense-in-depth: не отдаём текст исключения в HTTP-ответ.
+
+ Текст psycopg/httpx-исключений может содержать DSN-фрагменты, внутренние
+ хосты, пути. Логируем полный exception с коротким error-id через
+ ``logger.exception`` (уйдёт в GlitchTip), а в HTTP отдаём generic detail
+ с тем же error-id для корреляции.
+
+ ВАЖНО: вызывать только внутри ``except``-блока — ``logger.exception``
+ полагается на активный exc_info.
+ """
+ error_id = uuid4().hex[:8]
+ logger.exception("%s (error-id=%s)", log_context, error_id)
+ return HTTPException(status_code=status_code, detail=f"{public_detail} (error-id: {error_id})")
+
+
_ALL_SOURCES = ["avito", "cian", "yandex"]
@@ -547,14 +564,16 @@ async def scrape_avito_house(
try:
enrichment = await fetch_house_catalog(house_url)
except Exception as e:
- logger.exception("avito-house: fetch failed for %s", house_url)
- raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
+ raise _safe_http_error(
+ 502, "fetch failed", f"avito-house: fetch failed for {house_url}"
+ ) from e
try:
counters = save_house_catalog_enrichment(db, enrichment)
except Exception as e:
- logger.exception("avito-house: save failed for %s", house_url)
- raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
+ raise _safe_http_error(
+ 500, "save failed", f"avito-house: save failed for {house_url}"
+ ) from e
logger.info("avito-house ok: %s → %s", house_url, counters)
return {"ok": True, "house_url": house_url, "counters": counters}
@@ -576,14 +595,16 @@ async def scrape_avito_detail(
try:
enrichment = await fetch_detail(item_url)
except Exception as e:
- logger.exception("avito-detail: fetch failed for %s", item_url)
- raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
+ raise _safe_http_error(
+ 502, "fetch failed", f"avito-detail: fetch failed for {item_url}"
+ ) from e
try:
updated = save_detail_enrichment(db, enrichment)
except Exception as e:
- logger.exception("avito-detail: save failed for %s", item_url)
- raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
+ raise _safe_http_error(
+ 500, "save failed", f"avito-detail: save failed for {item_url}"
+ ) from e
if not updated:
# Listing с этим source_id ещё не существует в БД — был bypass через
@@ -625,8 +646,9 @@ async def scrape_avito_detail_backfill(
try:
result = await run_avito_detail_backfill(db, run_id=run_id, params=params)
except Exception as e:
- logger.exception("avito-detail-backfill: run_id=%d crashed", run_id)
- raise HTTPException(status_code=502, detail=f"backfill failed: {e}") from e
+ raise _safe_http_error(
+ 502, "backfill failed", f"avito-detail-backfill: run_id={run_id} crashed"
+ ) from e
logger.info("avito-detail-backfill ok: run_id=%d %s", run_id, result.to_dict())
return {"ok": True, "run_id": run_id, "counters": result.to_dict()}
@@ -666,17 +688,22 @@ async def scrape_avito_imv(
has_loggia=has_loggia,
)
except IMVAddressNotFoundError as e:
- raise HTTPException(status_code=404, detail=f"IMV address not found: {e}") from e
+ # Ожидаемое клиентское условие (адрес не в базе Avito), НЕ сбой — logger.warning
+ # без traceback, чтобы не шуметь exception-событиями в GlitchTip. Адрес — в лог,
+ # не в HTTP-ответ.
+ error_id = uuid4().hex[:8]
+ logger.warning("avito-imv: address not found for %s (error-id=%s)", address, error_id)
+ raise HTTPException(
+ status_code=404, detail=f"IMV address not found (error-id: {error_id})"
+ ) from e
except Exception as e:
- logger.exception("avito-imv: fetch failed for %s", address)
- raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
+ raise _safe_http_error(502, "fetch failed", f"avito-imv: fetch failed for {address}") from e
try:
eval_id = save_imv_evaluation(db, result)
history_saved = save_imv_placement_history(db, eval_id, result.placement_history)
except Exception as e:
- logger.exception("avito-imv: save failed for %s", address)
- raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
+ raise _safe_http_error(500, "save failed", f"avito-imv: save failed for {address}") from e
logger.info(
"avito-imv ok: addr=%s recommended=%d evaluation_id=%d history=%d",
diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py
index 2aae5075..d215df3c 100644
--- a/tradein-mvp/backend/app/api/v1/trade_in.py
+++ b/tradein-mvp/backend/app/api/v1/trade_in.py
@@ -518,11 +518,19 @@ async def upload_photo(
status_code=409, detail=f"photo limit reached ({_MAX_PHOTOS_PER_ESTIMATE})"
)
- content = await file.read()
+ # #2233: читаем тело чанками с жёстким капом, а не await file.read() целиком —
+ # иначе multi-GB аплоад буферизуется в RAM и OOM-killed backend (mem_limit 768m, #2214).
+ # 413 бросается СРАЗУ при превышении, остаток тела запроса НЕ читается.
+ chunks: list[bytes] = []
+ total = 0
+ while chunk := await file.read(64 * 1024):
+ total += len(chunk)
+ if total > _MAX_PHOTO_BYTES:
+ raise HTTPException(status_code=413, detail="file too large (max 10 MB)")
+ chunks.append(chunk)
+ content = b"".join(chunks)
if not content:
raise HTTPException(status_code=400, detail="empty file")
- if len(content) > _MAX_PHOTO_BYTES:
- raise HTTPException(status_code=413, detail="file too large (max 10 MB)")
# Sanitize: re-encode through Pillow to drop EXIF, kill polyglot payloads,
# cap dimensions. Closes finding #6 from 2026-05-24 audit.
diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py
index 1b790640..3583f0ac 100644
--- a/tradein-mvp/backend/tests/test_estimate_idor.py
+++ b/tradein-mvp/backend/tests/test_estimate_idor.py
@@ -491,6 +491,69 @@ def test_upload_photo_owner_can_upload(trade_in_app: FastAPI, monkeypatch) -> No
assert resp.status_code == 200
+class _InfiniteUploadFile:
+ """UploadFile stub whose async read(size) yields endless 64KB chunks (#2233).
+
+ Simulates a multi-GB stream. Counts read() calls so the test can assert the
+ handler stops reading right after the cap is exceeded instead of draining the
+ whole body into RAM.
+ """
+
+ content_type = "image/png"
+ filename = "huge.png"
+
+ def __init__(self) -> None:
+ self.read_calls = 0
+
+ async def read(self, size: int = -1) -> bytes:
+ self.read_calls += 1
+ return b"\x00" * (64 * 1024)
+
+
+async def test_upload_photo_streams_over_cap_returns_413_without_full_read() -> None:
+ """#2233: a stream larger than 10 MB → 413 raised early, body NOT fully read.
+
+ Acceptance: read() is called only ~(10MB/64KB)+1 times (161), proving the loop
+ bails on the first chunk that pushes total past _MAX_PHOTO_BYTES rather than
+ buffering an unbounded upload (which would OOM the 768m-capped container, #2214).
+ """
+ from uuid import UUID
+
+ from fastapi import HTTPException
+
+ import app.core.auth as auth_mod
+ from app.api.v1.trade_in import _MAX_PHOTO_BYTES, upload_photo
+
+ auth_mod.get_role = lambda _u: "pilot" # type: ignore[assignment]
+
+ # guard SELECT → owner; count SELECT → 0. INSERT must never be reached.
+ db = MagicMock()
+ guard_result = MagicMock()
+ guard_result.fetchone.return_value = SimpleNamespace(created_by="kopylov")
+ count_result = MagicMock()
+ count_result.scalar_one.return_value = 0
+ db.execute.side_effect = [guard_result, count_result]
+
+ upload = _InfiniteUploadFile()
+
+ with pytest.raises(HTTPException) as exc_info:
+ await upload_photo(
+ estimate_id=UUID(_ESTIMATE_ID),
+ db=db,
+ file=upload, # type: ignore[arg-type]
+ x_authenticated_user="kopylov",
+ )
+
+ assert exc_info.value.status_code == 413
+ expected_reads = _MAX_PHOTO_BYTES // (64 * 1024) + 1 # 161
+ assert upload.read_calls == expected_reads
+ # sanity: bounded, nowhere near infinite
+ assert upload.read_calls < 200
+ # INSERT (3rd execute) never fired — nothing persisted
+ assert db.execute.call_count == 2
+ db.commit.assert_not_called()
+
+
# ── Derived analytics routes (representative: /houses, /imv-benchmark) ────────
diff --git a/tradein-mvp/backend/tests/test_scraper_admin_apis.py b/tradein-mvp/backend/tests/test_scraper_admin_apis.py
index 497b1c46..4d1a4390 100644
--- a/tradein-mvp/backend/tests/test_scraper_admin_apis.py
+++ b/tradein-mvp/backend/tests/test_scraper_admin_apis.py
@@ -14,7 +14,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
from datetime import UTC, datetime
from typing import Any
-from unittest.mock import MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
@@ -564,3 +564,31 @@ def test_data_quality_pct_in_range(client: TestClient) -> None:
assert (
0.0 <= pct <= 100.0
), f"source={src['source']} field={field_name} pct={pct} вне [0,100]"
+
+
+# ── Security: текст исключения не утекает в HTTP-ответ (#2234) ────────────────
+
+
+def test_admin_error_detail_hides_exception_text(client: TestClient) -> None:
+ """Внутренняя зависимость кидает исключение с 'секретным' текстом →
+
+ в теле ответа НЕТ секрета, есть generic detail + error-id; статус-код прежний.
+ Роут /scrape/avito-detail: fetch_detail падает с DSN-подобным сообщением.
+ """
+ secret = "postgres://user:SECRETPASS@internal-host:5432/db"
+ with patch(
+ "app.api.v1.admin.fetch_detail",
+ new=AsyncMock(side_effect=Exception(secret)),
+ ):
+ r = client.post(
+ "/api/v1/admin/scrape/avito-detail",
+ params={"item_url": "/ekaterinburg/kvartiry/1-k_kvartira_100"},
+ )
+
+ assert r.status_code == 502
+ body = r.json()
+ detail = body["detail"]
+ assert "SECRETPASS" not in detail
+ assert secret not in detail
+ assert "error-id:" in detail
+ assert detail.startswith("fetch failed")
diff --git a/tradein-mvp/deploy/Caddyfile.tradein-fragment b/tradein-mvp/deploy/Caddyfile.tradein-fragment
index 73809453..4b22592f 100644
--- a/tradein-mvp/deploy/Caddyfile.tradein-fragment
+++ b/tradein-mvp/deploy/Caddyfile.tradein-fragment
@@ -15,6 +15,13 @@
handle_path /trade-in/api/* {
# handle_path вырезает префикс /trade-in перед прокси →
# FastAPI получает /api/v1/...
+ #
+ # #2233: первый рубеж против OOM на фото-аплоаде — режем тело >12MB на
+ # уровне Caddy (backend капит на 10MB чанково, см. upload_photo). Scoped
+ # ТОЛЬКО на /trade-in/api/* — не трогает фронт/остальной сайт.
+ request_body {
+ max_size 12MB
+ }
reverse_proxy tradein-backend:8000
}