From e6e7a8db1ccc678b699f51b4e83a3fec07fdc0ec Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 21:29:45 +0500 Subject: [PATCH] =?UTF-8?q?fix(mera):=20=D0=B6=D0=B8=D0=B2=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=B3=D0=BE=D0=BD=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D0=B9=20=D1=81?= =?UTF-8?q?=D1=85=D0=B5=D0=BC=D0=B5,=20=D0=B0=20=D0=BD=D0=B5=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BD=D0=B0=20=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B9=20(#3469)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тест применял 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 --- .../tests/test_3469_showcase_schedule.py | 60 +++++++++++++++---- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/tradein-mvp/backend/tests/test_3469_showcase_schedule.py b/tradein-mvp/backend/tests/test_3469_showcase_schedule.py index 1f8453c4..4394079b 100644 --- a/tradein-mvp/backend/tests/test_3469_showcase_schedule.py +++ b/tradein-mvp/backend/tests/test_3469_showcase_schedule.py @@ -25,14 +25,23 @@ повторное применение её не задваивает, и настоящий запрос сводки `_STALE_SOURCES_SQL` видит эту строку и отдаёт витрину просроченной. -ЖИВОЙ ТЕСТ НЕ УДАЛЯЕТ И НЕ ПРАВИТ НИЧЕГО ЧУЖОГО: он применяет те же идемпотентные -файлы, что применяет деплой (CREATE TABLE IF NOT EXISTS + ON CONFLICT DO NOTHING). -В CI он ИДЁТ — ci-tradein.yml поднимает свой Postgres и кладёт DSN в DATABASE_URL; -на машине без базы само-скипается (запись в tests/skip_allowlist.txt). Поднять -локально: +ЖИВОЙ ТЕСТ НЕ УДАЛЯЕТ И НЕ ПРАВИТ НИЧЕГО ЧУЖОГО: он применяет ту же идемпотентную +миграцию, что применяет деплой (ON CONFLICT DO NOTHING). В CI он ИДЁТ — +ci-tradein.yml поднимает свой Postgres и кладёт DSN в DATABASE_URL; на машине без +базы само-скипается (запись в 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 """ @@ -51,7 +60,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: 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" @@ -92,6 +101,13 @@ def test_handler_resolves_for_scheduler() -> None: 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 @@ -250,14 +266,29 @@ def test_live_migration_puts_showcase_into_schedules_and_digest() -> None: db = _live_session() assert db is not None 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) # идемпотентность: второй прогон не задваивает 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" ), @@ -265,9 +296,16 @@ def test_live_migration_puts_showcase_into_schedules_and_digest() -> None: ).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())