diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 277981f5..00c7a153 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -2693,6 +2693,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 @@ -2717,8 +2718,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 ВНУТРИ одного запроса @@ -2744,7 +2755,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 """ @@ -2793,8 +2807,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 @@ -2819,6 +2834,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"], @@ -2838,6 +2854,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) @@ -2846,20 +2874,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() @@ -2867,6 +2915,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 @@ -2879,6 +2934,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"], diff --git a/tradein-mvp/backend/app/services/proxy_pool.py b/tradein-mvp/backend/app/services/proxy_pool.py index bb5a83ff..57c9f29b 100644 --- a/tradein-mvp/backend/app/services/proxy_pool.py +++ b/tradein-mvp/backend/app/services/proxy_pool.py @@ -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, " diff --git a/tradein-mvp/backend/data/sql/209_scrape_proxies_disabled_reason.sql b/tradein-mvp/backend/data/sql/209_scrape_proxies_disabled_reason.sql new file mode 100644 index 00000000..87332618 --- /dev/null +++ b/tradein-mvp/backend/data/sql/209_scrape_proxies_disabled_reason.sql @@ -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; diff --git a/tradein-mvp/backend/tests/services/test_proxy_pool.py b/tradein-mvp/backend/tests/services/test_proxy_pool.py index 085810fa..56906738 100644 --- a/tradein-mvp/backend/tests/services/test_proxy_pool.py +++ b/tradein-mvp/backend/tests/services/test_proxy_pool.py @@ -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: diff --git a/tradein-mvp/backend/tests/test_admin_proxies.py b/tradein-mvp/backend/tests/test_admin_proxies.py index 277edf0b..f4619380 100644 --- a/tradein-mvp/backend/tests/test_admin_proxies.py +++ b/tradein-mvp/backend/tests/test_admin_proxies.py @@ -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