gendesign/tradein-mvp/backend/tests/test_2764_ban_kind_no_default.py
bot-backend 0de22f4bc9
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
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 2m59s
Deploy Trade-In / build-backend (push) Successful in 2m1s
Deploy Trade-In / deploy (push) Successful in 2m14s
fix(tradein/scraper): диагноз бана перестаёт назначаться по умолчанию (#2764) (#2765)
2026-08-06 23:17:59 +00:00

204 lines
9.5 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.

"""#2764: ban_kind перестаёт проставляться по умолчанию — метка либо доказана, либо 'unknown'.
Замер на проде 2026-08-07 (после миграции 218 и мержа #2711):
SELECT ban_kind, count(*) FROM scrape_runs WHERE status='banned' GROUP BY 1;
infra 92 / platform 41
Из них ДВЕ строки написаны уже в рантайме — и обе получили 'platform' по дефолту
сигнатуры mark_banned, из общего финализатора backfill-задач, который диагноз не
передаёт вовсе (3306 avito 12:42, 3313 domclick 15:20). Для avito причина 1600
отказов вообще не устанавливалась, и всё равно была записана блокировкой площадкой.
Проверяем четыре звена:
1. дефолт mark_banned = 'unknown' (обе копии runs-модуля) — test_2686 §4;
2. финализатор backfill'ов несёт диагноз, когда задача его знает, и 'unknown',
когда нет или когда блоки разошлись в диагнозах;
3. browser-режим fetch_detail больше не выдаёт отказ НАШЕГО сайдкара за
блокировку площадкой (зеркало #2686 для SERP-ветки);
4. 'unknown' проходит CHECK-констрейнт (миграция 234) — иначе запись упала бы
на проде, а падение внутри задачи выглядело бы как обычный failed-прогон.
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.avito_exceptions import (
AvitoBlockedError,
AvitoRateLimitedError,
AvitoSidecarUnavailableError,
)
from scraper_kit.orchestration.pipeline import ban_kind_of_exception
from scraper_kit.providers.avito.detail import fetch_detail
from app.services import scrape_runs as runs_mod
_BLOCKED_RUN = {"attempted": 5, "enriched": 0, "blocked": 5, "failed": 0}
def _ban_kind_of_finished(**kwargs: Any) -> str:
"""mark_backfill_finished на оборванном блоками прогоне → ban_kind, ушедший в mark_banned."""
recorded: list[str] = []
with patch.object(
runs_mod,
"mark_banned",
lambda db, rid, err, c, *, ban_kind: recorded.append(ban_kind),
):
runs_mod.mark_backfill_finished(
MagicMock(),
1,
dict(_BLOCKED_RUN),
source="avito_detail_backfill",
aborted_by_blocks=True,
**kwargs,
)
assert len(recorded) == 1, f"ожидался ровно один mark_banned, получено {recorded}"
return recorded[0]
# ── 1. Финализатор backfill'ов: диагноз или честное «не знаю» ────────────────
def test_finalizer_without_diagnosis_writes_unknown() -> None:
"""Задача типы не различает → 'unknown'.
Фальсификация: до правки этот вызывающий (единственный источник обеих
рантайм-строк на проде) не передавал ничего и получал дефолт 'platform'
assert падает на старом коде.
"""
assert _ban_kind_of_finished() == runs_mod.BAN_KIND_UNKNOWN
def test_finalizer_carries_the_diagnosis_when_all_blocks_agree() -> None:
"""Все блоки прогона — отказ нашего сайдкара → 'infra' доезжает до строки прогона."""
assert _ban_kind_of_finished(ban_kinds={runs_mod.BAN_KIND_INFRA}) == runs_mod.BAN_KIND_INFRA
assert (
_ban_kind_of_finished(ban_kinds=[runs_mod.BAN_KIND_PLATFORM, runs_mod.BAN_KIND_PLATFORM])
== runs_mod.BAN_KIND_PLATFORM
)
def test_finalizer_with_mixed_diagnoses_writes_unknown() -> None:
"""Блоки разошлись → 'unknown': какой из них оборвал прогон, мы не знаем.
Выбрать «последний попавшийся» значило бы вернуть назначенный диагноз под
другим именем.
"""
assert (
_ban_kind_of_finished(ban_kinds={runs_mod.BAN_KIND_INFRA, runs_mod.BAN_KIND_PLATFORM})
== runs_mod.BAN_KIND_UNKNOWN
)
# ── 2. Диагноз не врёт там, где он передаётся: browser-ветка fetch_detail ────
def _browser(*, side_effect: BaseException | None = None, html: str = "") -> Any:
bf = MagicMock()
bf.fetch = AsyncMock(side_effect=side_effect, return_value=html)
return bf
def _sidecar_503() -> httpx.HTTPStatusError:
"""Ровно то, что прод писал 90 раз с 05.07 по 03.08 (см. миграцию 218)."""
request = httpx.Request("POST", "http://tradein-browser:3000/fetch")
response = httpx.Response(
503, json={"error": "browser unavailable (proxy may be down)"}, request=request
)
return httpx.HTTPStatusError("503", request=request, response=response)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"sidecar_failure",
[_sidecar_503(), httpx.ConnectError("connection refused"), httpx.ReadTimeout("read timeout")],
)
async def test_browser_detail_sidecar_failure_is_infra(sidecar_failure: BaseException) -> None:
"""Сайдкар не отдал страницу → диагноз 'infra', поведение прогона прежнее.
Фальсификация: до правки ветка поднимала AvitoBlockedError на ЛЮБОЙ отказ
сайдкара, то есть ban_kind_of_exception возвращал 'platform' — «площадка нас
заблокировала» на странице, которая до площадки не доехала.
Второй assert стережёт совместимость: все существующие
`except (AvitoBlockedError, AvitoRateLimitedError)` обязаны продолжать ловить
этот отказ, иначе прогон уйдёт в mark_failed и потеряет чекпоинт.
"""
with pytest.raises(AvitoSidecarUnavailableError) as excinfo:
await fetch_detail("/items/1", browser_fetcher=_browser(side_effect=sidecar_failure))
assert isinstance(excinfo.value, AvitoRateLimitedError)
assert ban_kind_of_exception(excinfo.value) == runs_mod.BAN_KIND_INFRA
@pytest.mark.asyncio
async def test_browser_detail_firewall_page_stays_platform() -> None:
"""Площадка ПОКАЗАЛА firewall → 'platform' остаётся: правка сужает метку, а не снимает."""
firewall = "<html><body>Доступ ограничен: проблема с IP</body></html>"
with pytest.raises(AvitoBlockedError) as excinfo:
await fetch_detail("/items/1", browser_fetcher=_browser(html=firewall))
assert not isinstance(excinfo.value, AvitoSidecarUnavailableError)
assert ban_kind_of_exception(excinfo.value) == runs_mod.BAN_KIND_PLATFORM
# ── 3. 'unknown' проходит CHECK-констрейнт (миграция 234) ────────────────────
def _live_session() -> Any | None:
"""Session к живой Postgres, если она есть (в CI есть — ci-tradein.yml, #2745)."""
try:
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
if not dsn or "localhost:5432/test" in dsn:
return None
engine = create_engine(dsn, future=True)
conn = engine.connect()
conn.execute(text("SELECT 1"))
conn.close()
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_real_default_ban_kind_survives_the_check_constraint() -> None:
"""mark_banned без диагноза пишет 'unknown' в живую схему.
Фальсификация: без миграции 234 CHECK разрешает только platform/infra — UPDATE
падает на IntegrityError. На проде это падение было бы неотличимо от обычного
сбоя задачи (внешний `except Exception` backfill'а увёл бы прогон в
mark_failed, потеряв done_buckets-чекпоинт).
"""
from sqlalchemy import text
db = _live_session()
assert db is not None
try:
run_id = db.execute(
text(
"INSERT INTO scrape_runs (source, status) "
"VALUES ('test_2764', 'running') RETURNING id"
)
).scalar_one()
db.commit()
runs_mod.mark_banned(db, int(run_id), "test 2764", {"lots_fetched": 0})
row = db.execute(
text("SELECT status, ban_kind FROM scrape_runs WHERE id = :id"), {"id": run_id}
).one()
assert row.status == "banned"
assert row.ban_kind == runs_mod.BAN_KIND_UNKNOWN
finally:
db.execute(text("DELETE FROM scrape_runs WHERE source = 'test_2764'"))
db.commit()
db.close()