fix(tradein/proxy): отличать ручное выключение узла от авто-выключения (#2610) (#2652)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m35s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 1m17s

This commit is contained in:
bot-backend 2026-08-05 10:18:02 +00:00
parent 5162659277
commit aa5bb76822
5 changed files with 356 additions and 40 deletions

View file

@ -2646,6 +2646,7 @@ class ProxyRow(BaseModel):
provider_affinity: str
rotate_url: str | None # маскированный
enabled: bool
disabled_reason: str | None # #2610: NULL = не выключен вручную (авто-воскрешаем)
consecutive_fails: int
exit_ip: str | None
latency_ms: int | None
@ -2670,8 +2671,18 @@ def bulk_upsert_proxies(
Тело: {"proxies": [{"url", "provider_affinity", "kind"?, "rotate_url"?,
"label"?, "geo"?, "operator"?}, ...]}.
Существующий url DO UPDATE (affinity/kind/rotate_url + enabled=true,
label/geo/operator обновляются если переданы). Новый INSERT.
Существующий url DO UPDATE (affinity/kind/rotate_url + enabled,
label/geo/operator обновляются если переданы). Новый INSERT (enabled=true,
disabled_reason=NULL новый прокси не может быть "выключен вручную").
enabled на UPDATE-ветке НЕ безусловный (#2610): если у существующей строки
disabled_reason НЕ NULL (оператор снял узел с ротации вручную), bulk-upsert
(например повторный прогон загрузчика с тем же url) не должен тихо вернуть
его в строй тот же класс бага, что чинили в mark_health. enabled=true
ставится, только если disabled_reason IS NULL; сам disabled_reason bulk
не трогает (эта ручка не умеет ни ставить, ни снимать ручной флаг это
PATCH /proxies/{id}, см. patch_proxy).
Валидация provider_affinity/kind по whitelist на уровне Pydantic 422.
Возвращает {inserted, updated}. Дубли по url ВНУТРИ одного запроса
@ -2697,7 +2708,10 @@ def bulk_upsert_proxies(
label = COALESCE(EXCLUDED.label, scrape_proxies.label),
geo = COALESCE(EXCLUDED.geo, scrape_proxies.geo),
operator = COALESCE(EXCLUDED.operator, scrape_proxies.operator),
enabled = true,
enabled = CASE
WHEN scrape_proxies.disabled_reason IS NULL THEN true
ELSE scrape_proxies.enabled
END,
updated_at = now()
RETURNING (xmax = 0) AS was_inserted
"""
@ -2746,8 +2760,9 @@ def list_proxies(
text(
f"""
SELECT id, label, url, kind, provider_affinity, rotate_url, enabled,
consecutive_fails, exit_ip, latency_ms, last_check_at, last_ok_at,
leased_by, leased_at, geo, operator, expires_at, created_at, updated_at
disabled_reason, consecutive_fails, exit_ip, latency_ms,
last_check_at, last_ok_at, leased_by, leased_at, geo, operator,
expires_at, created_at, updated_at
FROM scrape_proxies
{where}
ORDER BY provider_affinity, id
@ -2772,6 +2787,7 @@ def list_proxies(
provider_affinity=r["provider_affinity"],
rotate_url=_mask_proxy_url(r["rotate_url"]),
enabled=r["enabled"],
disabled_reason=r["disabled_reason"],
consecutive_fails=r["consecutive_fails"],
exit_ip=r["exit_ip"],
latency_ms=r["latency_ms"],
@ -2791,6 +2807,18 @@ def list_proxies(
class ProxyPatch(BaseModel):
enabled: bool
reason: str | None = Field(
default=None,
max_length=500,
description=(
"Причина ручного выключения (#2610). Используется только когда enabled=false; "
"при отсутствии подставляется дефолтный текст. Игнорируется при enabled=true — "
"включение всегда сбрасывает disabled_reason в NULL."
),
)
_DEFAULT_MANUAL_DISABLE_REASON = "manually disabled via admin API"
@router.patch("/proxies/{proxy_id}", response_model=ProxyRow)
@ -2799,20 +2827,40 @@ def patch_proxy(
payload: ProxyPatch,
db: Annotated[Session, Depends(get_db)],
) -> ProxyRow:
"""Enable/disable одного прокси по id. 404 если не найден."""
"""Enable/disable одного прокси по id. 404 если не найден.
#2610: разводит "ручное выключение оператором" от "авто-выключение пулом".
enabled=false disabled_reason ставится (payload.reason либо дефолтный текст)
mark_health(ok=True) больше не воскресит узел молча первой успешной ipify-пробой.
enabled=true disabled_reason ОБЯЗАТЕЛЬНО сбрасывается в NULL иначе узел,
однажды выключенный руками, никогда больше не участвовал бы в авто-восстановлении
(см. proxy_pool.mark_health).
"""
row = (
db.execute(
text(
"""
UPDATE scrape_proxies
SET enabled = :enabled, updated_at = now()
SET enabled = :enabled,
disabled_reason = CASE
WHEN CAST(:enabled AS boolean) THEN NULL
ELSE COALESCE(CAST(:reason AS text), disabled_reason,
CAST(:default_reason AS text))
END,
updated_at = now()
WHERE id = :id
RETURNING id, label, url, kind, provider_affinity, rotate_url, enabled,
consecutive_fails, exit_ip, latency_ms, last_check_at, last_ok_at,
leased_by, leased_at, geo, operator, expires_at, created_at, updated_at
disabled_reason, consecutive_fails, exit_ip, latency_ms,
last_check_at, last_ok_at, leased_by, leased_at, geo, operator,
expires_at, created_at, updated_at
"""
),
{"enabled": payload.enabled, "id": proxy_id},
{
"enabled": payload.enabled,
"reason": payload.reason,
"default_reason": _DEFAULT_MANUAL_DISABLE_REASON,
"id": proxy_id,
},
)
.mappings()
.fetchone()
@ -2820,6 +2868,13 @@ def patch_proxy(
if row is None:
raise HTTPException(status_code=404, detail=f"proxy id={proxy_id} not found")
db.commit()
if not payload.enabled:
logger.info(
"proxy_pool: proxy id=%d manually disabled via admin API (reason=%r) — "
"auto-revive suspended until re-enabled (#2610)",
proxy_id,
row["disabled_reason"],
)
def _iso(v: Any) -> str | None:
return v.isoformat() if v is not None else None
@ -2832,6 +2887,7 @@ def patch_proxy(
provider_affinity=row["provider_affinity"],
rotate_url=_mask_proxy_url(row["rotate_url"]),
enabled=row["enabled"],
disabled_reason=row["disabled_reason"],
consecutive_fails=row["consecutive_fails"],
exit_ip=row["exit_ip"],
latency_ms=row["latency_ms"],

View file

@ -34,6 +34,18 @@ Self-healing (#2600):
enabled-узел выделенной affinity (пример domclick, один узел на всё, см. acquire
docstring) иначе чинили бы один источник ценой полной поломки другого.
Ручное выключение vs авто-выключение (#2610):
- scrape_proxies.disabled_reason (миграция 209) различает ДВЕ разные причины
enabled=false: пул выключил сам после серии сбоев (disabled_reason IS NULL)
воскрешается первой же успешной пробой, как задумано #2609; оператор выключил
руками через admin API (disabled_reason НЕ NULL) mark_health(ok=True) НЕ
трогает enabled, пишет WARNING с id узла и причиной. Без этого узел, снятый
оператором из ротации (например забаненный площадкой ipify через него всё
равно отвечает 200), возвращался бы в строй первой же health-пробой молча.
- Сброс флага (возврат к авто-восстанавливаемому состоянию) только через
admin API PATCH /proxies/{id} enabled=true (app/api/v1/admin.py:patch_proxy),
который явно обнуляет disabled_reason в NULL.
Sticky session lease (browser-путь, живая регрессия 2026-08):
- `BrowserFetcher` (scraper_kit) берёт ОДИН lease на весь жизненный цикл сессии
(весь прогон), а не на каждый `/fetch` иначе при N>=2 живых узлах пула каждый
@ -305,12 +317,19 @@ def mark_health(
) -> None:
"""Записать результат health-check'а прокси.
ok=True consecutive_fails обнуляется, enabled=true, обновляются last_ok_at/
last_check_at/exit_ip/latency_ms. enabled=true безусловно это реанимация:
узел, ранее выключенный auto-disable'ом, возвращается в строй первой же
успешной пробой (см. run_proxy_healthcheck, #2600 п.1).
ok=True consecutive_fails обнуляется, обновляются last_ok_at/last_check_at/
exit_ip/latency_ms. enabled=true РЕАНИМАЦИЯ, но ТОЛЬКО если узел не
выключен вручную (disabled_reason IS NULL, #2610): узел, ранее выключенный
auto-disable'ом (disabled_reason IS NULL), возвращается в строй первой же
успешной пробой, как задумано #2609 п.1. Узел, выключенный оператором
(disabled_reason НЕ NULL), остаётся enabled=false иначе снятый с ротации
забаненный площадкой узел воскрешался бы первой же ipify-пробой (ipify
площадку не эмулирует, значит бан ею не ловится). Этот случай логируется
WARNING'ом — раньше (до #2610) происходил молча.
ok=False consecutive_fails += 1; при достижении DISABLE_THRESHOLD прокси
авто-disable (enabled=false). last_check_at обновляется в любом случае.
авто-disable (enabled=false, disabled_reason НЕ трогается узел уходит в
disable БЕЗ причины, т.е. остаётся авто-воскрешаемым). last_check_at
обновляется в любом случае.
fail_kind необязательная классификация неуспеха ("timeout" / "connect_error" /
"http_error" / "other", см. _probe_proxy), используется ТОЛЬКО для логирования.
@ -321,22 +340,36 @@ def mark_health(
рамках #2600 п.2 — см. обоснование в PR. fail_kind — задел под это на будущее.
"""
if ok:
db.execute(
text(
"""
UPDATE scrape_proxies
SET consecutive_fails = 0,
last_ok_at = now(),
last_check_at = now(),
exit_ip = CAST(:exit_ip AS text),
latency_ms = CAST(:latency_ms AS integer),
enabled = true,
updated_at = now()
WHERE id = CAST(:id AS bigint)
"""
),
{"exit_ip": exit_ip, "latency_ms": latency_ms, "id": proxy_id},
row = (
db.execute(
text(
"""
UPDATE scrape_proxies
SET consecutive_fails = 0,
last_ok_at = now(),
last_check_at = now(),
exit_ip = CAST(:exit_ip AS text),
latency_ms = CAST(:latency_ms AS integer),
enabled = CASE
WHEN disabled_reason IS NULL THEN true ELSE enabled
END,
updated_at = now()
WHERE id = CAST(:id AS bigint)
RETURNING disabled_reason
"""
),
{"exit_ip": exit_ip, "latency_ms": latency_ms, "id": proxy_id},
)
.mappings()
.fetchone()
)
if row is not None and row["disabled_reason"] is not None:
logger.warning(
"proxy_pool: mark_health id=%d ok=True but stays disabled — manually "
"disabled (reason=%r), auto-revive skipped (#2610)",
proxy_id,
row["disabled_reason"],
)
else:
# consecutive_fails+1 >= порог → enabled=false (авто-вывод битого узла).
db.execute(
@ -453,7 +486,10 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
назад. Без этого auto-disable необратим узел, ушедший в disable из-за транзиентного
сбоя, никогда больше не проверяется и не может вернуться (#2600 п.1). Успешная проба
disabled-узла реанимирует его (enabled=true через mark_health) инкрементит `revived`
и пишет отдельный INFO-лог.
и пишет отдельный INFO-лог. Ручно-выключенные узлы (disabled_reason НЕ NULL, #2610)
тоже пробуются (чтобы после ручного включения признак немедленно ожил без ожидания
следующего disable/enable цикла), но mark_health их не воскрешает revived не растёт,
WARNING пишет сам mark_health.
Пробы идут последовательно пул небольшой (десятки узлов), а параллельный залп на
один и тот же upstream-endpoint (ipify) не нужен. Returns counters
@ -465,7 +501,7 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
db.execute(
text(
"""
SELECT id, url, kind, enabled
SELECT id, url, kind, enabled, disabled_reason
FROM scrape_proxies
WHERE enabled
OR last_check_at IS NULL
@ -489,12 +525,15 @@ async def run_proxy_healthcheck(db: Session) -> dict[str, int]:
proxy_id = int(row["id"])
url = str(row["url"])
was_disabled = not bool(row["enabled"])
manually_disabled = row["disabled_reason"] is not None
ok, exit_ip, latency_ms, fail_kind = await _probe_proxy(url)
mark_health(db, proxy_id, ok, exit_ip=exit_ip, latency_ms=latency_ms, fail_kind=fail_kind)
checked += 1
if ok:
ok_count += 1
if was_disabled:
# manually_disabled → mark_health не тронул enabled (см. её WARNING-лог);
# revived считает только реальное авто-воскрешение (#2610).
if was_disabled and not manually_disabled:
revived += 1
logger.info(
"proxy_pool: REVIVED proxy id=%d — successful probe of a disabled node, "

View file

@ -0,0 +1,53 @@
-- 209_scrape_proxies_disabled_reason.sql
-- scrape_proxies.disabled_reason — различить "оператор выключил руками" от
-- "пул выключил сам после серии сбоев" (#2610).
--
-- WHY:
-- #2609 сделало mark_health(ok=True) безусловно ставить enabled=true — чинит
-- реальный прод-инцидент (авто-выключенный после серии сбоев узел иначе не
-- проверяется больше никогда, транзиентный сбой = вечный приговор). Побочка:
-- оператор снимает узел с ротации через PATCH /proxies/{id} (enabled=false) —
-- первая же успешная ipify-проба молча возвращает его обратно. Особенно больно,
-- когда узел забанен площадкой (Авито): ipify через него ходит чисто (ipify
-- никого не банит), значит узел гарантированно вернётся, и оператор снимает
-- его снова и снова.
--
-- WHAT:
-- disabled_reason text, NULL по умолчанию. Семантика:
-- NULL — узел не выключен вручную (авто-disable по consecutive_fails —
-- тоже NULL здесь; поведение #2609 "авто-выключенный воскресает
-- первой успешной пробой" сохраняется без изменений).
-- текст — причина ручного выключения (по умолчанию из admin API —
-- "manually disabled via admin API", либо то, что передал оператор).
-- mark_health(ok=True) НЕ трогает enabled, пока это поле не NULL.
-- Сбрасывается в NULL ручным включением (PATCH .../{id} enabled=true).
--
-- ВЫБОР text, а не boolean manually_disabled: единственное текстовое поле
-- расширяемо под будущую ТРЕТЬЮ причину выключения без новой миграции — #2600
-- п.1 (сигнал бана площадкой до пула) заведёт значение вида 'banned:avito',
-- отличимое от 'manual' в логах/админке, и по той же самой колонке (не-NULL =
-- не воскрешать по голому ipify) без ALTER TABLE. Boolean такого не даёт —
-- потребовал бы либо второй колонки, либо потери "почему" при разборе.
--
-- IDEMPOTENCY / SAFETY:
-- - BEGIN/COMMIT — атомарно.
-- - ADD COLUMN IF NOT EXISTS — повторный прогон no-op (auto-apply strict).
-- - Без DEFAULT — существующие строки получают NULL, т.е. "не выключены
-- руками" — обратная совместимость: ни одна прод-строка не станет ручной.
--
-- Dependencies: 157_scrape_proxies.sql
BEGIN;
ALTER TABLE scrape_proxies
ADD COLUMN IF NOT EXISTS disabled_reason text;
COMMENT ON COLUMN scrape_proxies.disabled_reason IS
'NULL = не выключен вручную (mark_health(ok=True) может авто-восстановить '
'enabled=true, #2609). Текст = причина ручного выключения оператором '
'(PATCH /proxies/{id} enabled=false, admin.py) — ipify-проба узел НЕ '
'воскрешает, пока поле не NULL. Сбрасывается в NULL ручным включением. '
'Задел под #2600 п.1 (сигнал бана площадкой до пула) — третья причина '
'ляжет сюда же без новой миграции, напр. значением вида ''banned:avito''.';
COMMIT;

View file

@ -20,6 +20,12 @@ reap_stale_leases проверяются по фактическому изме
- run_proxy_healthcheck: disabled-узлы самовосстановление (#2600 п.1):
* успешная проба выключенного узла возвращает его в строй + revived++;
* недавно проверенный выключенный узел повторно не проверяется (не долбим провайдера).
- mark_health / disabled_reason (#2610 — ручное vs авто-выключение):
* авто-выключенный узел (disabled_reason IS NULL) по-прежнему воскресает
успешной пробой поведение #2609 не сломано;
* ручно-выключенный узел (disabled_reason НЕ NULL) НЕ воскресает даже при
ok=True, и это логируется (WARNING);
* run_proxy_healthcheck не считает ручно-выключенный узел в revived.
"""
from __future__ import annotations
@ -177,12 +183,18 @@ class FakeSession:
row["latency_ms"] = p["latency_ms"]
row["last_ok_at"] = datetime.now(UTC)
row["last_check_at"] = datetime.now(UTC)
# "SET consecutive_fails = 0" — общая подстрока старого И нового SQL,
# НЕ различает их сама по себе. Реанимация (enabled=true) — только если
# в тексте запроса реально есть присвоение enabled (#2600 review: старый
# мок ставил enabled=True безусловно и не ловил регресс).
if "enabled" in sql:
# Ручное выключение (#2610): реальный SQL реанимирует (enabled=true)
# ТОЛЬКО если disabled_reason IS NULL — мок обязан честно это
# воспроизвести, иначе тест не поймает регресс "снова безусловно
# enabled=true" (тот же класс бага, что был с "enabled" in sql до
# #2600 review).
if "disabled_reason IS NULL" in sql:
if row.get("disabled_reason") is None:
row["enabled"] = True
elif "enabled" in sql:
row["enabled"] = True
if "RETURNING disabled_reason" in sql:
return _FakeResult([{"disabled_reason": row.get("disabled_reason")}])
return _FakeResult([])
if "consecutive_fails = consecutive_fails + 1" in sql: # mark_health fail
@ -226,6 +238,7 @@ def _proxy(
last_check_at: datetime | None = None,
kind: str = "http",
rotate_url: str | None = None,
disabled_reason: str | None = None,
) -> dict[str, Any]:
return {
"id": pid,
@ -234,6 +247,7 @@ def _proxy(
"rotate_url": rotate_url,
"provider_affinity": affinity,
"enabled": enabled,
"disabled_reason": disabled_reason,
"consecutive_fails": fails,
"leased_by": leased_by,
"leased_at": leased_at,
@ -397,6 +411,37 @@ def test_mark_health_ok_revives_disabled_proxy() -> None:
assert row["consecutive_fails"] == 0
# ── mark_health / disabled_reason (#2610 — ручное vs авто-выключение) ──────────
#
# red/green контракт issue: (a) авто-выключенный узел воскресает по успешной пробе
# (#2609 не сломан — дублирует test_mark_health_ok_revives_disabled_proxy выше, но
# явно рядом с (b)/(c) для контраста), (b) ручно-выключенный НЕ воскресает + лог,
# (c) ручное включение сбрасывает флаг → узел снова авто-восстанавливаем (уровень
# admin API — см. tests/test_admin_proxies.py, mark_health сам флаг не трогает).
def test_mark_health_ok_revives_auto_disabled_proxy_a() -> None:
"""(a) Авто-выключенный (disabled_reason=NULL) воскресает — поведение #2609 сохранено."""
db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, disabled_reason=None)])
mark_health(db, 1, ok=True) # type: ignore[arg-type]
row = db._by_id(1)
assert row["enabled"] is True
assert row["consecutive_fails"] == 0
def test_mark_health_ok_does_not_revive_manually_disabled_proxy_b(
caplog: pytest.LogCaptureFixture,
) -> None:
"""(b) Ручно-выключенный (disabled_reason НЕ NULL) НЕ воскресает — и это логируется."""
db = FakeSession([_proxy(1, enabled=False, fails=DISABLE_THRESHOLD, disabled_reason="manual")])
with caplog.at_level("WARNING"):
mark_health(db, 1, ok=True) # type: ignore[arg-type]
row = db._by_id(1)
assert row["enabled"] is False # НЕ реанимирован, несмотря на ok=True
assert row["consecutive_fails"] == 0 # fails всё равно сбрасывается пробой
assert any("manually disabled" in rec.message for rec in caplog.records)
# ── reap_stale_leases ────────────────────────────────────────────────────────
@ -533,6 +578,39 @@ async def test_healthcheck_revives_disabled_proxy_on_success(
assert row["consecutive_fails"] == 0
async def test_healthcheck_does_not_revive_manually_disabled_proxy(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""(b)/(с) на уровне healthcheck: ручно-выключенный узел пробуется (recheck наступил),
но остаётся disabled даже при успешной пробе revived НЕ растёт (#2610)."""
stale_check = datetime.now(UTC) - timedelta(minutes=DISABLED_RECHECK_MINUTES + 5)
db = FakeSession(
[
_proxy(
1,
enabled=False,
fails=DISABLE_THRESHOLD,
last_check_at=stale_check,
disabled_reason="manual",
)
]
)
async def _fake_probe(url: str) -> tuple[bool, str | None, int | None, str | None]:
return True, "5.5.5.5", 30, None
monkeypatch.setattr(proxy_pool, "_probe_proxy", _fake_probe)
counters = await proxy_pool.run_proxy_healthcheck(db) # type: ignore[arg-type]
assert counters["checked"] == 1
assert counters["ok"] == 1
assert counters["revived"] == 0 # ручной флаг — mark_health не воскресил
row = db._by_id(1)
assert row["enabled"] is False # остался выключенным
assert row["consecutive_fails"] == 0 # проба всё равно сбросила счётчик fails
async def test_healthcheck_skips_recently_checked_disabled_proxy(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View file

@ -1,9 +1,11 @@
"""Offline-тесты admin proxy-pool ручек (#2161).
"""Offline-тесты admin proxy-pool ручек (#2161, #2610).
Покрытие (db мокается, NO live network/DB):
- POST /api/v1/admin/proxies/bulk UPSERT-счётчики, валидация affinity/kind
- GET /api/v1/admin/proxies маскировка пароля, фильтры
- GET /api/v1/admin/proxies маскировка пароля, фильтры, disabled_reason в ответе
- PATCH /api/v1/admin/proxies/{id} enable/disable, 404
- #2610: PATCH enabled=false ставит disabled_reason (ручное выключение отличимо от
авто); PATCH enabled=true сбрасывает disabled_reason в NULL (снова авто-восстанавливаем)
"""
from __future__ import annotations
@ -133,6 +135,7 @@ def _proxy_db_row(**over: Any) -> dict[str, Any]:
"provider_affinity": "avito",
"rotate_url": "http://user:secret@rot:9000/changeip",
"enabled": True,
"disabled_reason": None,
"consecutive_fails": 0,
"exit_ip": "1.2.3.4",
"latency_ms": 120,
@ -164,6 +167,22 @@ def test_list_masks_password(client: TestClient, db: MagicMock) -> None:
assert "secret" not in r.text
def test_list_exposes_disabled_reason(client: TestClient, db: MagicMock) -> None:
"""(#2610) Оператор должен видеть причину ручного выключения в листинге."""
result = MagicMock()
result.mappings.return_value.all.return_value = [
_proxy_db_row(id=1, enabled=False, disabled_reason=None),
_proxy_db_row(id=2, enabled=False, disabled_reason="забанен Авито"),
]
db.execute.return_value = result
r = client.get("/api/v1/admin/proxies")
assert r.status_code == 200, r.text
rows = {row["id"]: row for row in r.json()}
assert rows[1]["disabled_reason"] is None # авто-выключен
assert rows[2]["disabled_reason"] == "забанен Авито" # выключен вручную
def test_list_passes_filters(client: TestClient, db: MagicMock) -> None:
result = MagicMock()
result.mappings.return_value.all.return_value = []
@ -197,3 +216,74 @@ def test_patch_not_found_404(client: TestClient, db: MagicMock) -> None:
r = client.patch("/api/v1/admin/proxies/999", json={"enabled": True})
assert r.status_code == 404
# ── PATCH /proxies/{id} — disabled_reason (#2610) ───────────────────────────
def test_patch_disable_sets_disabled_reason_default(client: TestClient, db: MagicMock) -> None:
"""(#2610) Ручное выключение без явной причины — ставится дефолтный текст."""
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(
enabled=False, disabled_reason="manually disabled via admin API"
)
db.execute.return_value = result
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False})
assert r.status_code == 200, r.text
assert r.json()["disabled_reason"] == "manually disabled via admin API"
# дефолтная причина реально передана в SQL как fallback-параметр
params = db.execute.call_args.args[1]
assert params["default_reason"]
assert params["reason"] is None
def test_patch_disable_sets_disabled_reason_custom(client: TestClient, db: MagicMock) -> None:
"""(#2610) Оператор передаёт свою причину — она уходит в SQL-параметры."""
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(
enabled=False, disabled_reason="забанен Авито"
)
db.execute.return_value = result
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": False, "reason": "забанен Авито"})
assert r.status_code == 200, r.text
assert r.json()["disabled_reason"] == "забанен Авито"
params = db.execute.call_args.args[1]
assert params["reason"] == "забанен Авито"
def test_patch_enable_clears_disabled_reason(client: TestClient, db: MagicMock) -> None:
"""(#2610, red/green c) Ручное включение сбрасывает disabled_reason → узел снова
авто-восстанавливаем через mark_health."""
result = MagicMock()
result.mappings.return_value.fetchone.return_value = _proxy_db_row(
enabled=True, disabled_reason=None
)
db.execute.return_value = result
r = client.patch("/api/v1/admin/proxies/1", json={"enabled": True})
assert r.status_code == 200, r.text
assert r.json()["disabled_reason"] is None
params = db.execute.call_args.args[1]
assert params["enabled"] is True
# ── POST /proxies/bulk — не глушит ручной disable (#2610) ──────────────────
def test_bulk_upsert_sql_preserves_manual_disable_on_conflict(
client: TestClient, db: MagicMock
) -> None:
"""Bulk-upsert (например повторный прогон загрузчика) не должен молча вернуть в
строй узел, выключенный оператором вручную ON CONFLICT SET enabled должен
проверять disabled_reason существующей строки, а не ставить true безусловно."""
db.execute.side_effect = [_scalar_result(False)]
r = client.post(
"/api/v1/admin/proxies/bulk",
json={"proxies": [{"url": "http://u:p@host1:8080", "provider_affinity": "avito"}]},
)
assert r.status_code == 200, r.text
sql_text = str(db.execute.call_args.args[0])
assert "disabled_reason IS NULL" in sql_text
assert "scrape_proxies.enabled" in sql_text # fallback — не трогает при ручном disable