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
Тест применял 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>
324 lines
19 KiB
Python
324 lines
19 KiB
Python
"""Витрина сделок лэндинга попадает в расписание и в монитор свежести (#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()
|