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
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:
parent
59482cae85
commit
e6e7a8db1c
1 changed files with 49 additions and 11 deletions
|
|
@ -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())
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue