gendesign/tradein-mvp/backend/tests/test_3469_showcase_schedule.py
bot-backend e6e7a8db1c
All checks were successful
CI / changes (pull_request) Successful in 12s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m52s
CI Trade-In / frontend-checks (pull_request) Successful in 1m17s
fix(mera): живой тест витрины гоняется на полной схеме, а не только на пустой (#3469)
Тест применял 015/051/052 безусловно, и на базе, прошедшей всю цепочку
миграций (CI и прод), повтор 015 падал:

  psycopg.errors.UndefinedColumn: column "returning_count" of relation
  "scrape_runs" does not exist

Файл 015 идемпотентен относительно себя, но не относительно схемы,
прошедшей 214 (DROP COLUMN IF EXISTS returning_count): CREATE TABLE IF
NOT EXISTS — no-op, а COMMENT ON COLUMN в конце того же файла обращается
к снесённой колонке. На чистой базе, где 015 ложится с нуля, этого не
видно по построению — потому прогон и был зелёным там, где его гонял я,
и красным там, где его гоняет CI.

Зависимости теперь применяются только когда scrape_schedules ещё нет; в
докстроке — рецепт прогона на ПОЛНОЙ схеме, тем же путём, что у CI.

Заодно закрыты три дыры, которые находились мутациями:

- окно расписания и повторное применение проверяются на живой БД (до
  этого 6,7 → 6,23 и DELETE+INSERT вместо ON CONFLICT проходили насквозь);
  идемпотентность меряется created_at строки, а не числом строк — замена
  «удалить и вставить» тоже оставляет ровно одну строку, но стирает
  last_run_at/next_run_at на каждом деплое;
- next_run_at в будущем — утверждение стояло в приёмке и ничем не
  проверялось;
- handler сравнивается по САМОМУ job'у, а не по log_name: имя — второй
  литерал конструктора Handler, и чужое тело под верным ключом
  (_job_landing_stats) проходило проверку по имени.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:29:45 +05:00

324 lines
19 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.

"""Витрина сделок лэндинга попадает в расписание и в монитор свежести (#3469).
ЧТО БЫЛО СЛОМАНО. `landing_showcase_deals` считает витрину публичного лэндинга
(«МЕРА сказала X — продали за Y»), но в `scrape_schedules` строки для неё не было
вовсе (`WHERE source LIKE '%showcase%'` — 0 строк на проде 12.09.2026), а в
реестре `product_handlers` — обработчика. То есть планировщик про задачу не знал
ни с какой стороны, пересчёт был ручным, и страница показывала прогон
тринадцатисуточной давности. Заметить это было неоткуда: под таблицей печатались
счётчики прогона, но не его дата, а сводка просроченных источников
(`emit_stale_digest`, #2670) ходит по ВКЛЮЧЁННЫМ РАСПИСАНИЯМ — источника, которого
в таблице нет, для неё не существует.
ЧТО ПРОВЕРЯЕТСЯ ЗДЕСЬ, И ПОЧЕМУ ИМЕННО ЭТО.
1. Обработчик резолвится ТЕМ ЖЕ `resolve_handler`, которым его ищет боевой
`_dispatch`. Одной строки расписания мало: без обработчика планировщик
нашёл бы задачу и не смог её запустить.
2. Миграция 303 сеет строку, и сеет её ВКЛЮЧЁННОЙ с явным `interval_days` —
сводка считает порог просрочки из этого же числа.
3. Сводка краснеет, когда витрина не пересчитывалась дольше ТРЁХ тактов
(приёмка #3469), и молчит на двух. Число тактов здесь — литерал, а такт
читается из миграции: ожидание, взятое из той же настройки, которую
проверяешь, уезжает вместе с ней — см. комментарий при _ACCEPTANCE_CYCLES.
4. Живой Postgres (само-скип): миграция реально вставляет строку в таблицу,
повторное применение её не задваивает, и настоящий запрос сводки
`_STALE_SOURCES_SQL` видит эту строку и отдаёт витрину просроченной.
ЖИВОЙ ТЕСТ НЕ УДАЛЯЕТ И НЕ ПРАВИТ НИЧЕГО ЧУЖОГО: он применяет ту же идемпотентную
миграцию, что применяет деплой (ON CONFLICT DO NOTHING). В CI он ИДЁТ —
ci-tradein.yml поднимает свой Postgres и кладёт DSN в DATABASE_URL; на машине без
базы само-скипается (запись в tests/skip_allowlist.txt).
ГОНЯТЬ ЕГО НАДО НА ПОЛНОЙ СХЕМЕ, А НЕ НА ПУСТОЙ БАЗЕ. На чистой базе он был
зелёным и при этом падал в CI: повтор `015_scrape_runs.sql` (его комментарий к
колонке, снесённой миграцией 214) на полной схеме валится, а на пустой — нет.
Поэтому зависимости применяются только когда таблицы ещё нет, а проверять надо
тем же путём, каким гоняет CI:
docker exec tradein-postgres psql -U tradein -d postgres -c 'CREATE DATABASE t3469full'
docker exec -i tradein-postgres psql -U tradein -d t3469full -c \\
'CREATE EXTENSION postgis; CREATE EXTENSION pg_trgm; CREATE ROLE gendesign_reader;'
for f in $(ls -1 data/sql/*.sql | sort); do docker exec -i tradein-postgres \\
psql -U tradein -d t3469full -v ON_ERROR_STOP=on -q < "$f"; done
DATABASE_URL="postgresql+psycopg://tradein:tradein@127.0.0.1:5433/t3469full" \\
uv run python -m pytest tests/test_3469_showcase_schedule.py -q
"""
from __future__ import annotations
import os
import re
from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.orchestration import scheduler as sched
from app.services.product_handlers import _job_landing_showcase_deals, build_product_handlers
SOURCE = "landing_showcase_deals"
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
_MIGRATION = _SQL_DIR / "303_scrape_schedules_seed_landing_showcase_deals.sql"
# Таблицы, без которых строку расписания некуда класть (FK scrape_schedules →
# scrape_runs), — живой тест применяет их в том же порядке, что и деплой.
_DEPS = [
_SQL_DIR / "015_scrape_runs.sql",
# counters jsonb — по нему сводка судит, принёс ли прогон данные.
_SQL_DIR / "051_scrape_runs_extend.sql",
_SQL_DIR / "052_scrape_schedules.sql",
]
NOW = datetime(2026, 9, 12, 8, 0, tzinfo=UTC)
def _migration_sql() -> str:
return _MIGRATION.read_text("utf-8")
def _seeded_interval_days() -> int:
"""Такт из САМОЙ миграции — порог сводки считается из него, не из литерала."""
m = re.search(r'"interval_days"\s*:\s*(\d+)', _migration_sql())
assert m is not None, "в default_params миграции 303 нет interval_days"
return int(m.group(1))
# ── 1. Планировщик видит задачу ──────────────────────────────────────────────
def test_handler_resolves_for_scheduler() -> None:
"""`resolve_handler` находит витрину — тем же вызовом, что и боевой _dispatch.
Ломать так: убрать ключ из реестра в product_handlers — тест покраснеет, а
планировщик на проде заклеймил бы прогон и не нашёл, чем его выполнить.
"""
registry = build_product_handlers(ctx=None) # type: ignore[arg-type]
handler = sched.resolve_handler(SOURCE, registry)
assert handler is not None, f"{SOURCE} не резолвится реестром — задача невидима"
# СРАВНИВАЕМ САМ JOB, А НЕ `log_name`: имя — второй литерал конструктора
# Handler, и правильный ключ с чужим телом (`_job_landing_stats` под ключом
# витрины) проходил проверку по имени насквозь. Резолв ведёт к пересчёту
# витрины или не ведёт — это свойство функции, а не подписи в логе.
assert handler.job is _job_landing_showcase_deals, (
f"под ключом {SOURCE} стоит чужой job: {handler.job.__name__}"
)
assert handler.log_name == SOURCE
# ── 2. Миграция сеет строку ──────────────────────────────────────────────────
def test_migration_303_exists() -> None:
assert _MIGRATION.is_file(), f"missing migration: {_MIGRATION}"
def test_migration_303_seeds_source_enabled() -> None:
sql = _migration_sql()
assert f"'{SOURCE}'" in sql
assert "INSERT INTO scrape_schedules" in sql
# enabled=true — иначе сводка просроченных источников строку не увидит
# (_STALE_SOURCES_SQL: WHERE sch.enabled), и монитор молчал бы как раньше.
assert re.search(rf"'{SOURCE}',\s*\n\s*true", sql), "расписание засеяно выключенным"
def test_migration_303_is_idempotent_and_transactional() -> None:
sql = _migration_sql()
assert "ON CONFLICT (source) DO NOTHING" in sql
assert "BEGIN;" in sql
assert "COMMIT;" in sql
def test_migration_303_no_psycopg_trap() -> None:
assert not re.search(r":\w+::", _migration_sql())
def test_migration_303_interval_days_is_daily() -> None:
"""Такт суточный: витрина устаревает от КОДА (деплой), а не от квартальных сделок."""
assert _seeded_interval_days() == 1
# ── 3. Сводка свежести краснеет на молчащей витрине ──────────────────────────
def _row(age_days: float, *, status: str | None = "done") -> Any:
"""Строка `_STALE_SOURCES_SQL`: прогон витрины `age_days` суток назад.
`status=None` (LEFT JOIN не нашёл прогонов) — витрину не пересчитывали ни разу
с момента появления расписания; тогда возраст считается от created_at строки.
"""
finished = None if status is None else NOW - timedelta(days=age_days)
return SimpleNamespace(
source=SOURCE,
interval_days=str(_seeded_interval_days()),
created_at=NOW - timedelta(days=age_days),
finished_at=finished,
status=status,
counters={"considered": 200, "eligible": 161, "written": 20},
)
def _stale_now(rows: list[Any]) -> list[sched.StaleSource]:
return sched.stale_sources(sched.freshness_rows(rows), NOW)
# Приёмка issue #3469 дословно: «отсутствие прогона дольше 3× такта даёт тревогу».
# ЧИСЛО ЗДЕСЬ ЛИТЕРАЛ, А НЕ `sched.STALE_DIGEST_INTERVAL_FACTOR`. Взятое из той же
# настройки, которую проверяем, ожидание уезжает вместе с ней: при факторе 3650
# ЭТИ ЖЕ тесты оставались зелёными (проверено руками), то есть проверяли ровно
# ничего. Такт (`interval_days`) при этом читается из миграции — правило «3×»
# и задано в тактах, а не в сутках.
_ACCEPTANCE_CYCLES = 3
def test_digest_flags_showcase_after_three_cycles() -> None:
"""Нет пересчёта дольше 3× такта → витрина в сводке просроченных."""
lag = _ACCEPTANCE_CYCLES * _seeded_interval_days() + 0.5
stale = _stale_now([_row(lag)])
assert [s.source for s in stale] == [SOURCE], (
f"витрина молчит {lag} суток при такте {_seeded_interval_days()} и не в тревоге"
)
assert stale[0].interval_days == _seeded_interval_days()
def test_digest_silent_within_cycle() -> None:
"""Контроль: два такта — ещё норма, иначе тревога кричала бы всегда."""
lag = 2 * _seeded_interval_days()
assert _stale_now([_row(lag)]) == []
def test_digest_flags_showcase_that_never_ran() -> None:
"""Расписание есть, прогонов нет — самый частый вид молчания (#3469 и был им)."""
lag = _ACCEPTANCE_CYCLES * _seeded_interval_days() + 1
stale = _stale_now([_row(lag, status=None)])
assert [s.source for s in stale] == [SOURCE]
assert stale[0].never_ok is True
def test_showcase_counters_do_not_fake_freshness() -> None:
"""Свежесть даёт ПРОГОН, а не его счётчики.
`run_brought_data` судит по результатным ключам kit'а, а у витрины их нет
(`considered`/`eligible`/`written` — свой словарь). Значит мерой остаётся
успешный статус: прогон, свалившийся в failed, свежести не даёт.
"""
assert sched.run_brought_data("done", {"considered": 200, "written": 20}) is True
assert sched.run_brought_data("failed", {"considered": 200, "written": 0}) is False
# ── 4. Живая БД: строка реально ложится в таблицу ────────────────────────────
def _live_session() -> Any | None:
"""Session на живой Postgres — та же проба, что у соседних живых тестов.
`TEST_DATABASE_URL` имеет приоритет; `localhost:5432/test` — заглушка модулей,
её не считаем базой. В CI сюда приезжает DSN поднятого в job'е контейнера
(ci-tradein.yml), поэтому проверка там ИДЁТ, а не тихо скипается.
"""
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)
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
return sessionmaker(bind=engine, future=True)()
except Exception:
return None
def _apply(db: Any, path: Path) -> None:
"""Прогнать файл миграции целиком, одним куском — как `psql -f` на деплое.
Через ДРАЙВЕРНОЕ соединение, а не `exec_driver_sql`: последний отдаёт текст
psycopg вместе с пустым набором параметров, и тот начинает искать в нём
плейсхолдеры — любой процент в комментарии миграции («полоса 5..+20 %»)
роняет запуск ошибкой про `%`. psql такого разбора не делает, так что это
артефакт теста, а не свойство файла.
"""
db.connection().connection.driver_connection.execute(path.read_text("utf-8"))
db.commit()
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
def test_live_migration_puts_showcase_into_schedules_and_digest() -> None:
"""Миграция кладёт строку в scrape_schedules, и сводка видит витрину просроченной.
Значение, а не текст файла: применяем 303 на живой базе (дважды — дублей быть
не должно), читаем строку обратно и прогоняем настоящий `_STALE_SOURCES_SQL` —
тот же запрос, которым сводка судит на проде.
Ничего не удаляем: обе миграции идемпотентны (CREATE TABLE IF NOT EXISTS /
ON CONFLICT DO NOTHING), то есть повтор здесь — ровно то же действие, что и
повторный деплой.
"""
from sqlalchemy import text
db = _live_session()
assert db is not None
try:
# Зависимости — ТОЛЬКО на пустой базе. На базе, прошедшей всю цепочку
# (CI и прод), повтор 015 падает: `CREATE TABLE IF NOT EXISTS` — no-op,
# а `COMMENT ON COLUMN scrape_runs.returning_count` внизу того же файла
# обращается к колонке, которую снесла 214. Файл идемпотентен
# относительно себя, но не относительно схемы, прошедшей 214, — и
# прогон на чистой базе этого не видит по построению.
if db.execute(text("SELECT to_regclass('public.scrape_schedules')")).scalar() is None:
for dep in _DEPS:
_apply(db, dep)
_apply(db, _MIGRATION)
# ИДЕМПОТЕНТНОСТЬ МЕРЯЕТСЯ ПО СОСТОЯНИЮ СТРОКИ, А НЕ ПО ЧИСЛУ СТРОК.
# «DELETE + INSERT» тоже оставляет ровно одну строку, но на КАЖДОМ
# деплое стирает last_run_at/next_run_at и взводит расписание заново —
# счёт строк такую замену не отличает, а created_at отличает.
first_created_at = db.execute(
text("SELECT created_at FROM scrape_schedules WHERE source = :s"), {"s": SOURCE}
).scalar()
_apply(db, _MIGRATION)
rows = db.execute(
text(
"SELECT enabled, window_start_hour, window_end_hour, created_at, "
" (next_run_at > now()) AS next_run_ahead, "
" default_params->>'interval_days' AS interval_days "
"FROM scrape_schedules WHERE source = :s"
),
{"s": SOURCE},
).fetchall()
assert len(rows) == 1, f"ожидалась одна строка расписания, получено {len(rows)}"
row = rows[0]
assert row.created_at == first_created_at, (
"повторное применение пересоздало строку расписания — на каждом деплое "
"это стирало бы состояние прогонов (last_run_at/next_run_at)"
)
assert row.enabled is True
assert (row.window_start_hour, row.window_end_hour) == (6, 7)
assert int(row.interval_days) == _seeded_interval_days()
# next_run_at в БУДУЩЕМ: сев расписания не должен выстреливать прогоном
# в момент деплоя (образец — 162/275). Стоит в приёмке, значит и здесь.
assert row.next_run_ahead is True, "next_run_at в прошлом — прогон стартует на деплое"
# Сводка: прогонов у витрины нет, возраст считается от created_at строки.
digest_rows = list(db.execute(sched._STALE_SOURCES_SQL).fetchall())
assert SOURCE in {r.source for r in digest_rows}, "сводка не видит витрину"
overdue_at = row.created_at + timedelta(
days=_ACCEPTANCE_CYCLES * _seeded_interval_days() + 0.5
)
stale = sched.stale_sources(sched.freshness_rows(digest_rows), overdue_at)
assert SOURCE in {s.source for s in stale}, "витрина без прогонов не попала в тревогу"
fresh_at = row.created_at + timedelta(hours=1)
fresh = sched.stale_sources(sched.freshness_rows(digest_rows), fresh_at)
assert SOURCE not in {s.source for s in fresh}, "тревога сразу после сева — ложная"
finally:
db.close()