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())