diff --git a/tradein-mvp/backend/app/services/product_handlers.py b/tradein-mvp/backend/app/services/product_handlers.py index 42630b40..7cc7d6d6 100644 --- a/tradein-mvp/backend/app/services/product_handlers.py +++ b/tradein-mvp/backend/app/services/product_handlers.py @@ -357,7 +357,23 @@ async def _job_landing_showcase_deals( counters = await loop.run_in_executor( None, lambda: refresh_landing_showcase_deals(db, **kwargs) ) - ctx.runs.mark_done(db, run_id, counters) + # #3511: пустая витрина — не успех. Результатного ключа кита + # (`_RESULT_COUNTER_KEYS`) у счётчиков витрины нет, сводка просроченных + # судит её по статусу, и `done` с written=0 гасил часы свежести так же, + # как удачный прогон, — а страница тем временем теряла таблицу целиком. + if not counters.get("written"): + logger.error( + "landing_showcase_deals run_id=%d: витрина пуста (written=0, " + "considered=%s, eligible=%s) — прогон неуспешен", + run_id, + counters.get("considered"), + counters.get("eligible"), + ) + ctx.runs.mark_failed( + db, run_id, "витрина пуста: прогон не записал ни одной строки (written=0)", counters + ) + else: + ctx.runs.mark_done(db, run_id, counters) except Exception: logger.exception("scheduler: landing_showcase_deals crashed run_id=%d", run_id) ctx.runs.mark_failed(db, run_id, "landing_showcase_deals failed", {}) diff --git a/tradein-mvp/backend/tests/test_3469_showcase_schedule.py b/tradein-mvp/backend/tests/test_3469_showcase_schedule.py index 4394079b..79a9a5b0 100644 --- a/tradein-mvp/backend/tests/test_3469_showcase_schedule.py +++ b/tradein-mvp/backend/tests/test_3469_showcase_schedule.py @@ -53,6 +53,7 @@ from datetime import UTC, datetime, timedelta from pathlib import Path from types import SimpleNamespace from typing import Any +from unittest.mock import MagicMock, patch import pytest @@ -211,6 +212,87 @@ def test_showcase_counters_do_not_fake_freshness() -> None: assert sched.run_brought_data("failed", {"considered": 200, "written": 0}) is False +# ── 3а. Пустой прогон витрины не гасит часы свежести (#3511) ──────────────── + + +class _RecordedRuns: + """`ctx.runs`, который кладёт финал прогона строкой `_STALE_SOURCES_SQL`. + + Тогда тест идёт ТЕМ ЖЕ путём, что и сводка на проде: обработчик ставит статус, + `freshness_rows` судит, принёс ли прогон данные, `stale_sources` — просрочку. + Проверка «вызван mark_failed» такой путь не проходит: она зелёная и тогда, + когда статус верный, а мера свежести его не видит. + """ + + def __init__(self) -> None: + self.rows: list[Any] = [] + self.finished_at = NOW + + def _row(self, status: str, counters: dict[str, Any]) -> None: + self.rows.append( + SimpleNamespace( + source=SOURCE, + interval_days=str(_seeded_interval_days()), + created_at=NOW - timedelta(days=30), + finished_at=self.finished_at, + status=status, + counters=counters, + ) + ) + + def mark_done(self, db: Any, run_id: int, counters: dict[str, Any]) -> None: + self._row("done", counters) + + def mark_failed(self, db: Any, run_id: int, error: str, counters: dict[str, Any]) -> None: + self._row("failed", counters) + + +async def _run_handler(runs: _RecordedRuns, counters: dict[str, int], age_days: float) -> None: + runs.finished_at = NOW - timedelta(days=age_days) + with patch( + "app.tasks.landing_showcase_deals.refresh_landing_showcase_deals", + return_value=counters, + ): + await _job_landing_showcase_deals(MagicMock(), 1, {}, SimpleNamespace(runs=runs)) + + +_FULL = {"considered": 200, "priced": 180, "eligible": 160, "written": 20} +_EMPTY = {"considered": 200, "priced": 180, "eligible": 160, "written": 0} + + +@pytest.mark.asyncio +async def test_empty_showcase_run_does_not_reset_freshness() -> None: + """Последний непустой прогон старше 3× такта, вчерашний пустой → витрина в тревоге. + + До #3511 пустой прогон завершался `done`, и `run_brought_data` (судит по + статусу: результатного ключа кита у витрины нет) засчитывал его свежестью — + опустевший блок лэндинга молчал бы сколько угодно. + """ + runs = _RecordedRuns() + await _run_handler(runs, _FULL, age_days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 1) + await _run_handler(runs, _EMPTY, age_days=1) + + stale = _stale_now(runs.rows) + assert [s.source for s in stale] == [SOURCE], ( + f"пустой прогон витрины засчитан свежестью: статусы {[r.status for r in runs.rows]}" + ) + assert stale[0].age_days > _ACCEPTANCE_CYCLES * _seeded_interval_days() + + +@pytest.mark.asyncio +async def test_nonempty_showcase_run_keeps_freshness() -> None: + """Контроль: тот же сценарий с непустым последним прогоном — тревоги нет. + + Зелёный с обеих сторон правки; без него правка могла бы валить любой прогон. + """ + runs = _RecordedRuns() + await _run_handler(runs, _FULL, age_days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 1) + await _run_handler(runs, _FULL, age_days=1) + + assert _stale_now(runs.rows) == [] + assert [r.status for r in runs.rows] == ["done", "done"] + + # ── 4. Живая БД: строка реально ложится в таблицу ────────────────────────────