All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m33s
В шапке модуля, в комментарии миграции 264 и в докстринге теста стояло «23 687 из 44 744 avito-объявлений не подтверждались >7 суток» под заголовком «ЗАМЕР НА ПРОДЕ». Число реальное, но приписано не тому. Перепроверено запросом 2026-08-15: это ВСЕ источники вместе, и две трети — новостройки, которых оценщик не берёт (он фильтрует listing_segment IS NULL OR = 'vtorichka'). У самого avito просроченных строк ноль: 8 663 активных, максимальный возраст 10 суток. Оставлять это в коде нельзя: следующий человек прочитает «avito раздут вдвое», проверит и не найдёт — а заодно потеряет доверие к остальным числам в том же абзаце, которые верны и сверены с scrape_runs.counters. Заодно явно записано, чего потолок НЕ делает: он не сжимает пул (0 деактиваций замерено на всех четырёх джобах), а защищает от разгона пола и от опечатки в расписании. Настоящий раздутый срез — строки с пустым сегментом, они чинятся отдельной джобой.
443 lines
22 KiB
Python
443 lines
22 KiB
Python
"""Потолок эффективного TTL деактивации (найдено на проде 2026-08-15).
|
||
|
||
Пол TTL по измеренному циклу переобхода (#2659, deactivate_stale_avito.py) поднимает
|
||
эффективный TTL через max(ttl_days, пол) без верхней границы. На проде это оказалось
|
||
петлёй с положительной обратной связью: медленный обход поднимает пол, высокий пол
|
||
продлевает жизнь снятым лотам дольше, чем к ним успевает вернуться свежий обход, пул
|
||
«активных» раздувается протухшими строками. У yandex ttl_days_effective держали
|
||
75/75/75/39/52/54 шесть прогонов подряд при deactivated=0 -- это и есть разгон пола,
|
||
ради которого потолок написан. Цифру «23 687 из 44 744» из исходного разбора сюда НЕ
|
||
переносим: она про все источники сразу, две трети её -- новостройки вне выборки
|
||
оценщика, а у самого avito просроченных строк ноль (уточнено 2026-08-15).
|
||
|
||
Этот файл проверяет CAP_MULT -- потолок, не пускающий эффективный TTL выше
|
||
ttl_days * CAP_MULT, независимо от того, насколько высоко посчитанный пол.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||
|
||
from app.tasks import deactivate_stale_avito as task_mod
|
||
|
||
# ── Фейковая сессия (тот же контракт, что в test_deactivate_stale_revisit_floor.py) ──
|
||
|
||
|
||
class _FakeResult:
|
||
def __init__(self, rowcount: int = 0, scalar_value: Any = None) -> None:
|
||
self.rowcount = rowcount
|
||
self._scalar = scalar_value
|
||
|
||
def scalar(self) -> Any:
|
||
return self._scalar
|
||
|
||
|
||
class _FakeDB:
|
||
"""Session-заглушка: percentile_disc -> пол, count(*) -> подтверждения, UPDATE -> rowcount."""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
floor_days: float | None,
|
||
confirmations: int = 10_000,
|
||
rowcount: int = 137,
|
||
) -> None:
|
||
self._floor = floor_days
|
||
self._confirmations = confirmations
|
||
self._rowcount = rowcount
|
||
self.executed: list[tuple[str, dict[str, Any] | None]] = []
|
||
self.committed = False
|
||
self.rolled_back = False
|
||
|
||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||
sql = str(stmt.text)
|
||
self.executed.append((sql, params))
|
||
if "percentile_disc" in sql:
|
||
return _FakeResult(scalar_value=self._floor)
|
||
if "SELECT count(*)" in sql:
|
||
return _FakeResult(scalar_value=self._confirmations)
|
||
return _FakeResult(rowcount=self._rowcount)
|
||
|
||
def commit(self) -> None:
|
||
self.committed = True
|
||
|
||
def rollback(self) -> None:
|
||
self.rolled_back = True
|
||
|
||
@property
|
||
def update_query(self) -> tuple[str, dict[str, Any] | None]:
|
||
return next((e for e in self.executed if "UPDATE listings" in e[0]), ("", None))
|
||
|
||
|
||
def _run(db: _FakeDB, monkeypatch: pytest.MonkeyPatch, **kwargs: Any) -> dict[str, int]:
|
||
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
||
monkeypatch.setattr(task_mod.runs_mod, "mark_failed", lambda *a, **k: None)
|
||
return task_mod.deactivate_stale_listings(
|
||
db, # type: ignore[arg-type]
|
||
1,
|
||
listing_source=kwargs.pop("listing_source", "avito"),
|
||
ttl_days=kwargs.pop("ttl_days", 10),
|
||
**kwargs,
|
||
)
|
||
|
||
|
||
# ── Контракт из задачи ─────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_high_floor_is_capped_at_double_ttl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""revisit_floor=75, ttl_days=10 -> итог 20 (потолок 2x), НЕ 75."""
|
||
db = _FakeDB(floor_days=75.0)
|
||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||
assert out["ttl_days_effective"] == 20
|
||
assert out["ttl_floor_capped"] == 1
|
||
assert out["ttl_days_floor_raw"] == 75
|
||
_, update_params = db.update_query
|
||
assert update_params is not None
|
||
assert update_params["ttl_days"] == 20, "UPDATE обязан получить капнутый TTL, не сырой пол"
|
||
|
||
|
||
def test_low_floor_leaves_ttl_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""revisit_floor=5, ttl_days=10 -> итог 10 (пол ниже заданного TTL, max() его не поднимает)."""
|
||
db = _FakeDB(floor_days=5.0)
|
||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||
assert out["ttl_days_effective"] == 10
|
||
assert "ttl_floor_capped" not in out
|
||
assert "ttl_days_floor_raw" not in out
|
||
_, update_params = db.update_query
|
||
assert update_params is not None
|
||
assert update_params["ttl_days"] == 10
|
||
|
||
|
||
# ── Контракт потолка ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_cap_mult_is_named_module_constant_equal_two() -> None:
|
||
assert task_mod.CAP_MULT == 2
|
||
|
||
|
||
def test_floor_between_ttl_and_cap_is_not_flagged_capped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Пол поднял TTL, но не дотянулся до потолка -- capped-флаг НЕ выставляется."""
|
||
db = _FakeDB(floor_days=15.0)
|
||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||
assert out["ttl_days_effective"] == 15
|
||
assert "ttl_floor_capped" not in out
|
||
|
||
|
||
def test_floor_exactly_at_cap_boundary_is_not_flagged_capped(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Пол ровно на потолке (2x ttl) -- это ещё "поднят до потолка", не "срезан выше него".
|
||
|
||
Формула -- min(raw, cap): при raw == cap срезания не происходит (raw > cap ложно),
|
||
капнутый флаг предназначен сигналить именно "потолок реально что-то отрезал".
|
||
"""
|
||
db = _FakeDB(floor_days=20.0)
|
||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||
assert out["ttl_days_effective"] == 20
|
||
assert "ttl_floor_capped" not in out
|
||
|
||
|
||
def test_cap_logs_warning_containing_both_numbers(
|
||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||
) -> None:
|
||
"""WARNING при срезании содержит и сырой пол, и капнутый результат -- не только counters."""
|
||
db = _FakeDB(floor_days=75.0)
|
||
with caplog.at_level("WARNING", logger=task_mod.logger.name):
|
||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||
messages = " ".join(r.getMessage() for r in caplog.records)
|
||
assert "75" in messages, "лог обязан называть сырой пол"
|
||
assert "20" in messages, "лог обязан называть итоговый (капнутый) TTL"
|
||
|
||
|
||
def test_cap_never_lowers_ttl_below_configured_value(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Потолок -- верхняя граница, не альтернативный источник истины: заданный TTL
|
||
(10) остаётся нижней границей независимо от того, насколько низко ушёл пол."""
|
||
db = _FakeDB(floor_days=1.0)
|
||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||
assert out["ttl_days_effective"] == 10
|
||
|
||
|
||
# ── avito self-descend (52 -> ... -> 10) не должен ломаться потолком ────────────
|
||
|
||
|
||
def test_avito_high_transient_floor_is_capped_not_left_unbounded(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Наблюдённый на проде транзиентный пик avito (счётчики видели ttl_days_effective=52)
|
||
теперь капается на 2x ttl=20, а не пропускается в UPDATE как есть."""
|
||
db = _FakeDB(floor_days=52.0)
|
||
out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99)
|
||
assert out["ttl_days_effective"] == 20
|
||
assert out["ttl_floor_capped"] == 1
|
||
assert out["ttl_days_floor_raw"] == 52
|
||
|
||
|
||
def test_avito_recovered_low_floor_still_reaches_configured_ttl(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""После восстановления обхода (пол опустился ниже ttl_days=10, как на проде 52->10)
|
||
потолок не мешает нормальному пути -- эффективный TTL просто равен заданному."""
|
||
db = _FakeDB(floor_days=9.0)
|
||
out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99)
|
||
assert out["ttl_days_effective"] == 10
|
||
assert "ttl_floor_capped" not in out
|
||
|
||
|
||
def test_avito_floor_above_ttl_but_under_cap_passes_through_uncapped(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Промежуточная точка того же самопонижения (пол между ttl и потолком, например 18)
|
||
поднимает TTL как раньше -- потолок не мешает нормальному постепенному пути."""
|
||
db = _FakeDB(floor_days=18.0)
|
||
out = _run(db, monkeypatch, listing_source="avito", ttl_days=10, revisit_floor_quantile=0.99)
|
||
assert out["ttl_days_effective"] == 18
|
||
assert "ttl_floor_capped" not in out
|
||
|
||
|
||
# ── cap_mult конфигурируем per-source (найдено ревью 2026-08-15) ────────────────
|
||
# Дефолтный CAP_MULT=2 даёт разный АБСОЛЮТНЫЙ потолок на разных источниках
|
||
# (cian/yandex 60 сут, avito 20 сут), а хвост переобхода не пропорционален
|
||
# ttl_days: avito p99=42.1 -- выше его же дефолтного потолка 20. cap_mult -- ручка
|
||
# для конкретно такого источника, без изменения дефолта для остальных.
|
||
|
||
|
||
def test_cap_mult_defaults_to_module_constant_when_not_overridden(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""Без явного cap_mult поведение не меняется: потолок = ttl_days * CAP_MULT (2)."""
|
||
db = _FakeDB(floor_days=75.0)
|
||
out = _run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99)
|
||
assert out["ttl_days_effective"] == 10 * task_mod.CAP_MULT
|
||
|
||
|
||
def test_cap_mult_override_raises_the_ceiling_for_a_long_tailed_source(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""avito p99=42.1: cap_mult=6 (потолок 60) больше не режет пол ниже хвоста обхода,
|
||
в отличие от дефолтного cap_mult=2 (потолок 20)."""
|
||
db = _FakeDB(floor_days=45.0)
|
||
out = _run(
|
||
db,
|
||
monkeypatch,
|
||
listing_source="avito",
|
||
ttl_days=10,
|
||
revisit_floor_quantile=0.99,
|
||
cap_mult=6,
|
||
)
|
||
assert out["ttl_days_effective"] == 45
|
||
assert "ttl_floor_capped" not in out
|
||
|
||
|
||
def test_cap_mult_override_still_caps_when_floor_exceeds_the_wider_ceiling(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
"""cap_mult поднимает потолок, но не убирает его -- пол выше 60 всё равно срезается."""
|
||
db = _FakeDB(floor_days=90.0)
|
||
out = _run(
|
||
db,
|
||
monkeypatch,
|
||
listing_source="avito",
|
||
ttl_days=10,
|
||
revisit_floor_quantile=0.99,
|
||
cap_mult=6,
|
||
)
|
||
assert out["ttl_days_effective"] == 60
|
||
assert out["ttl_floor_capped"] == 1
|
||
assert out["ttl_days_floor_raw"] == 90
|
||
|
||
|
||
def test_cap_mult_is_threaded_into_update_params(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Капнутый по override'нутому потолку TTL реально уходит в UPDATE, не только считается."""
|
||
db = _FakeDB(floor_days=90.0)
|
||
_run(
|
||
db,
|
||
monkeypatch,
|
||
listing_source="avito",
|
||
ttl_days=10,
|
||
revisit_floor_quantile=0.99,
|
||
cap_mult=6,
|
||
)
|
||
_, update_params = db.update_query
|
||
assert update_params is not None
|
||
assert update_params["ttl_days"] == 60
|
||
|
||
|
||
# ── ttl_days <= 0 (LOW из ревью 2026-08-15) ──────────────────────────────────────
|
||
# До потолка max(ttl_days, floor) прикрывал ttl_days<=0, если пол посчитан и
|
||
# положителен. С потолком min(raw, ttl_days * cap_mult) при ttl_days<=0 капнутый
|
||
# потолок тоже <= 0 и побеждает в min() -- защита пола пропадает молча. Явный guard
|
||
# ловит это ДО любого SQL, тем же путём, что и невалидный staleness_column.
|
||
|
||
|
||
def test_ttl_days_zero_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
db = _FakeDB(floor_days=75.0)
|
||
with pytest.raises(ValueError):
|
||
_run(db, monkeypatch, ttl_days=0, revisit_floor_quantile=0.99)
|
||
assert db.executed == []
|
||
|
||
|
||
def test_ttl_days_negative_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
db = _FakeDB(floor_days=75.0)
|
||
with pytest.raises(ValueError):
|
||
_run(db, monkeypatch, ttl_days=-5, revisit_floor_quantile=0.99)
|
||
assert db.executed == []
|
||
|
||
|
||
def test_ttl_days_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Тот же контракт, что и невалидный staleness_column: run помечается failed,
|
||
а не остаётся 'running'."""
|
||
marked_failed: list[Any] = []
|
||
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
||
monkeypatch.setattr(
|
||
task_mod.runs_mod,
|
||
"mark_failed",
|
||
lambda db, run_id, err, counters: marked_failed.append((run_id, err, counters)),
|
||
)
|
||
db = _FakeDB(floor_days=75.0)
|
||
with pytest.raises(ValueError):
|
||
task_mod.deactivate_stale_listings(
|
||
db, # type: ignore[arg-type]
|
||
7,
|
||
listing_source="avito",
|
||
ttl_days=0,
|
||
)
|
||
assert len(marked_failed) == 1
|
||
assert marked_failed[0][0] == 7
|
||
|
||
|
||
# ── cap_mult < 1 (HIGH из ревью круга 2, 2026-08-15) ─────────────────────────────
|
||
# Тот же класс дыры, что и ttl_days<=0 выше, но со стороны потолка: cap_mult -- ЕДИНСТВЕННЫЙ
|
||
# запланированный способ его задать -- руками вписать в jsonb default_params расписания
|
||
# (см. миграцию для avito), т.е. именно там опечатка 0 / 0.5 вместо 6 доходит до прода.
|
||
# cap_mult=0 -> capped=0 -> effective_ttl_days=0 -> UPDATE снимает весь активный пул
|
||
# источника молча. cap_mult<1 (например 0.5) опускает потолок НИЖЕ заданного оператором
|
||
# ttl_days -- прямое нарушение инварианта, который проверяет
|
||
# test_cap_never_lowers_ttl_below_configured_value для пола, но не было проверено для
|
||
# потолка при некорректном cap_mult.
|
||
|
||
|
||
def test_cap_mult_zero_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
db = _FakeDB(floor_days=52.0)
|
||
with pytest.raises(ValueError):
|
||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=0)
|
||
assert db.executed == []
|
||
|
||
|
||
def test_cap_mult_negative_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
db = _FakeDB(floor_days=52.0)
|
||
with pytest.raises(ValueError):
|
||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=-2)
|
||
assert db.executed == []
|
||
|
||
|
||
def test_cap_mult_below_one_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""cap_mult=0.5 опустил бы потолок НИЖЕ заданного ttl_days -- та самая инверсия,
|
||
которую тест test_cap_never_lowers_ttl_below_configured_value гарантирует для пола."""
|
||
db = _FakeDB(floor_days=52.0)
|
||
with pytest.raises(ValueError):
|
||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=0.5)
|
||
assert db.executed == []
|
||
|
||
|
||
def test_cap_mult_non_numeric_fails_safe_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Опечатка в jsonb default_params (строка вместо числа) не должна молча пройти
|
||
в SQL -- TypeError из сравнения `cap_mult < 1` ловится тем же except Exception,
|
||
что и ValueError-гварды, и маршрутизируется через mark_failed. Никакого SQL не
|
||
исполняется, ни один active-лот не тронут."""
|
||
db = _FakeDB(floor_days=52.0)
|
||
with pytest.raises(TypeError):
|
||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult="6")
|
||
assert db.executed == []
|
||
|
||
|
||
# ── cap_mult / ttl_days -- bool (найдено ревью круга 3, 2026-08-15) ─────────────
|
||
# bool -- подкласс int в Python: `True < 1` ложно, `True <= 0` ложно. Числовые
|
||
# guard'ы выше (`cap_mult < 1`, `ttl_days <= 0`) поэтому НЕ ловят jsonb `true` в
|
||
# default_params расписания -- ровно тот класс опечатки, ради которого guard'ы
|
||
# вообще написаны. `cap_mult=True` даёт потолок == ttl_days (ttl_days * True ==
|
||
# ttl_days) -- пол молча отключается без единого ValueError. `ttl_days=True` даёт
|
||
# ttl_days == 1 -- TTL молча меняется на 1 сутки. Явная type-проверка ловит оба
|
||
# ДО числового сравнения и ДО любого SQL.
|
||
|
||
|
||
def test_cap_mult_true_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""cap_mult=True: `True < 1` ложно -- без явной type-проверки потолок = ttl_days
|
||
(пол молча отключается) вместо ValueError. Воспроизведено на HEAD ветки."""
|
||
db = _FakeDB(floor_days=52.0)
|
||
with pytest.raises(ValueError):
|
||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=True)
|
||
assert db.executed == []
|
||
|
||
|
||
def test_cap_mult_false_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""cap_mult=False уже ловится `cap_mult < 1` (False == 0), но type-guard идёт
|
||
первым -- проверяем, что путь всё равно ValueError, а не иной exception."""
|
||
db = _FakeDB(floor_days=52.0)
|
||
with pytest.raises(ValueError):
|
||
_run(db, monkeypatch, ttl_days=10, revisit_floor_quantile=0.99, cap_mult=False)
|
||
assert db.executed == []
|
||
|
||
|
||
def test_ttl_days_true_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""ttl_days=True: `True <= 0` ложно -- без явной type-проверки TTL молча
|
||
становится 1 сутки (True ведёт себя как int 1) вместо ValueError."""
|
||
db = _FakeDB(floor_days=52.0)
|
||
with pytest.raises(ValueError):
|
||
_run(db, monkeypatch, ttl_days=True, revisit_floor_quantile=0.99)
|
||
assert db.executed == []
|
||
|
||
|
||
def test_ttl_days_false_is_rejected_before_any_sql(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""ttl_days=False уже ловится `ttl_days <= 0` (False == 0), но type-guard идёт
|
||
первым -- проверяем, что путь всё равно ValueError."""
|
||
db = _FakeDB(floor_days=52.0)
|
||
with pytest.raises(ValueError):
|
||
_run(db, monkeypatch, ttl_days=False, revisit_floor_quantile=0.99)
|
||
assert db.executed == []
|
||
|
||
|
||
def test_cap_mult_zero_fails_the_run_via_mark_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
"""Тот же контракт, что и ttl_days<=0: run помечается failed, а не остаётся 'running',
|
||
и НИ ОДНА строка не деактивируется (в отличие от воспроизведённого на HEAD дефекта, где
|
||
cap_mult=0 давало effective_ttl_days=0 и снимало весь активный пул источника)."""
|
||
marked_failed: list[Any] = []
|
||
monkeypatch.setattr(task_mod.runs_mod, "mark_done", lambda *a, **k: None)
|
||
monkeypatch.setattr(
|
||
task_mod.runs_mod,
|
||
"mark_failed",
|
||
lambda db, run_id, err, counters: marked_failed.append((run_id, err, counters)),
|
||
)
|
||
db = _FakeDB(floor_days=52.0)
|
||
with pytest.raises(ValueError):
|
||
task_mod.deactivate_stale_listings(
|
||
db, # type: ignore[arg-type]
|
||
9,
|
||
listing_source="avito",
|
||
ttl_days=10,
|
||
revisit_floor_quantile=0.99,
|
||
cap_mult=0,
|
||
)
|
||
assert len(marked_failed) == 1
|
||
assert marked_failed[0][0] == 9
|
||
assert db.executed == []
|
||
|
||
|
||
# ── проводка cap_mult в product_handlers ─────────────────────────────────────────
|
||
|
||
|
||
def test_handler_wires_cap_mult_from_schedule_params() -> None:
|
||
"""Тот же приём, что test_handler_wires_revisit_floor_from_schedule_params:
|
||
читаем исходник файлом (product_handlers тянет scraper_kit, которого в юнит-
|
||
окружении может не быть) и проверяем именно проводку default_params -> вызов."""
|
||
handlers = Path(__file__).resolve().parents[1] / "app" / "services" / "product_handlers.py"
|
||
src = handlers.read_text("utf-8")
|
||
job = src.split("async def _job_deactivate_stale")[1].split("\nasync def ")[0]
|
||
flat = " ".join(job.split())
|
||
assert 'params.get("cap_mult", CAP_MULT)' in flat
|
||
assert "cap_mult=cap_mult" in job
|