fix(mera): живой тест витрины гоняется на полной схеме, а не только на пустой (#3469)
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>
This commit is contained in:
bot-backend 2026-09-12 21:29:45 +05:00
parent 59482cae85
commit e6e7a8db1c

View file

@ -25,14 +25,23 @@
повторное применение её не задваивает, и настоящий запрос сводки повторное применение её не задваивает, и настоящий запрос сводки
`_STALE_SOURCES_SQL` видит эту строку и отдаёт витрину просроченной. `_STALE_SOURCES_SQL` видит эту строку и отдаёт витрину просроченной.
ЖИВОЙ ТЕСТ НЕ УДАЛЯЕТ И НЕ ПРАВИТ НИЧЕГО ЧУЖОГО: он применяет те же идемпотентные ЖИВОЙ ТЕСТ НЕ УДАЛЯЕТ И НЕ ПРАВИТ НИЧЕГО ЧУЖОГО: он применяет ту же идемпотентную
файлы, что применяет деплой (CREATE TABLE IF NOT EXISTS + ON CONFLICT DO NOTHING). миграцию, что применяет деплой (ON CONFLICT DO NOTHING). В CI он ИДЁТ
В CI он ИДЁТ ci-tradein.yml поднимает свой Postgres и кладёт DSN в DATABASE_URL; ci-tradein.yml поднимает свой Postgres и кладёт DSN в DATABASE_URL; на машине без
на машине без базы само-скипается (запись в tests/skip_allowlist.txt). Поднять базы само-скипается (запись в tests/skip_allowlist.txt).
локально:
docker exec tradein-postgres psql -U tradein -c 'CREATE DATABASE t3469' ГОНЯТЬ ЕГО НАДО НА ПОЛНОЙ СХЕМЕ, А НЕ НА ПУСТОЙ БАЗЕ. На чистой базе он был
DATABASE_URL="postgresql+psycopg://tradein:tradein@127.0.0.1:5433/t3469" \ зелёным и при этом падал в 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 uv run python -m pytest tests/test_3469_showcase_schedule.py -q
""" """
@ -51,7 +60,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
from scraper_kit.orchestration import scheduler as sched from scraper_kit.orchestration import scheduler as sched
from app.services.product_handlers import build_product_handlers from app.services.product_handlers import _job_landing_showcase_deals, build_product_handlers
SOURCE = "landing_showcase_deals" SOURCE = "landing_showcase_deals"
@ -92,6 +101,13 @@ def test_handler_resolves_for_scheduler() -> None:
registry = build_product_handlers(ctx=None) # type: ignore[arg-type] registry = build_product_handlers(ctx=None) # type: ignore[arg-type]
handler = sched.resolve_handler(SOURCE, registry) handler = sched.resolve_handler(SOURCE, registry)
assert handler is not None, f"{SOURCE} не резолвится реестром — задача невидима" 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 assert handler.log_name == SOURCE
@ -250,14 +266,29 @@ def test_live_migration_puts_showcase_into_schedules_and_digest() -> None:
db = _live_session() db = _live_session()
assert db is not None assert db is not None
try: try:
for dep in _DEPS: # Зависимости — ТОЛЬКО на пустой базе. На базе, прошедшей всю цепочку
_apply(db, dep) # (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) _apply(db, _MIGRATION)
_apply(db, _MIGRATION) # идемпотентность: второй прогон не задваивает
rows = db.execute( rows = db.execute(
text( text(
"SELECT enabled, window_start_hour, window_end_hour, created_at, " "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 " " default_params->>'interval_days' AS interval_days "
"FROM scrape_schedules WHERE source = :s" "FROM scrape_schedules WHERE source = :s"
), ),
@ -265,9 +296,16 @@ def test_live_migration_puts_showcase_into_schedules_and_digest() -> None:
).fetchall() ).fetchall()
assert len(rows) == 1, f"ожидалась одна строка расписания, получено {len(rows)}" assert len(rows) == 1, f"ожидалась одна строка расписания, получено {len(rows)}"
row = rows[0] row = rows[0]
assert row.created_at == first_created_at, (
"повторное применение пересоздало строку расписания — на каждом деплое "
"это стирало бы состояние прогонов (last_run_at/next_run_at)"
)
assert row.enabled is True assert row.enabled is True
assert (row.window_start_hour, row.window_end_hour) == (6, 7) assert (row.window_start_hour, row.window_end_hour) == (6, 7)
assert int(row.interval_days) == _seeded_interval_days() 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 строки. # Сводка: прогонов у витрины нет, возраст считается от created_at строки.
digest_rows = list(db.execute(sched._STALE_SOURCES_SQL).fetchall()) digest_rows = list(db.execute(sched._STALE_SOURCES_SQL).fetchall())