diff --git a/.claude/rules/tradein.md b/.claude/rules/tradein.md index f54f8d34..3eb19d62 100644 --- a/.claude/rules/tradein.md +++ b/.claude/rules/tradein.md @@ -41,9 +41,21 @@ In-app scheduler (`scrape_schedules`, tick 60s, `python -m app.scheduler_main`, `tradein-mvp/backend/data/sql/NN_*.sql` применяется автоматически на деплое через `_schema_migrations` в `.forgejo/workflows/deploy-tradein.yml` (НЕ init-only, strict exit-1). Idempotency критична — -деструктивный DDL хитит прод на деплое. NN-нумерация уже 3-значная и ИМЕЕТ коллизии (`108_*` ×2, -`084_*` ×2) → перед новым файлом `ls tradein-mvp/backend/data/sql | grep '^NN'` на дубль basename, -не доверяй `tail`. +деструктивный DDL хитит прод на деплое. + +**Номер новой миграции сверяй с `origin/main`, не с локальным `ls`** — локальное дерево не видит +миграций, смерженных после ветвления (так разъехались 212 в #2682 и 234 в #2754): + +```bash +git fetch origin main +git ls-tree -r --name-only origin/main -- tradein-mvp/backend/data/sql | tail +``` + +`-r` обязателен — без него `ls-tree` печатает сам каталог одной строкой, а не файлы. + +Правило целиком — в докстринге `tradein-mvp/backend/tests/test_migration_numbering.py` (единственная +формулировка контракта, #2683); он же гейтит его в CI. Дописывать имя в какой-либо список НЕ надо: +`_manifest_applied.txt` удалён — он отставал и по построению не мог покраснеть. ## Rapid-merge trap diff --git a/.forgejo/workflows/ci-tradein.yml b/.forgejo/workflows/ci-tradein.yml index 522cba14..79b7ca10 100644 --- a/.forgejo/workflows/ci-tradein.yml +++ b/.forgejo/workflows/ci-tradein.yml @@ -85,6 +85,21 @@ jobs: CI_PG: ci-pg-tradein-${{ github.run_id }} steps: - uses: actions/checkout@v4 + with: + # ПОЛНАЯ история, а не дефолтный depth=1 (#2683). + # tests/test_migration_numbering.py сверяет номер новой миграции с + # origin/main и точкой ветвления — на shallow-клоне нет ни того, ни + # другого, и гейт красится «нет git-эталона». Это намеренно: молча + # пропущенная проверка и есть тот зелёный, который ничего не проверяет. + # Пак репозитория ~33 MiB, полный fetch дешевле разбора коллизии на проде. + fetch-depth: 0 + + - name: Дотянуть origin/main (эталон номеров миграций) + # checkout выкачивает ТОЛЬКО ref события: на pull_request это + # refs/pull/N/head (merge-ref этот Forgejo не публикует — ls-remote видит + # 1620 */head и ноль */merge), так что ветки main в клоне нет вовсе. + # Без этого шага сверять номера не с чем. + run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - name: Поднять Postgres и собрать схему tradein working-directory: . diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index b93af07c..b0567716 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -149,6 +149,13 @@ jobs: DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test steps: - uses: actions/checkout@v4 + with: + # Как в ci-tradein.yml: tests/test_migration_numbering.py (#2683) требует + # origin/main и общего предка с HEAD. На depth=1 их нет, и гейт красный. + fetch-depth: 0 + + - name: Дотянуть origin/main (эталон номеров миграций) + run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - name: Install uv # Официальный standalone-инсталлер: системный `pip install uv` на @@ -396,8 +403,9 @@ jobs: # Tracking через _schema_migrations (порт паттерна из deploy.yml): # каждый .sql применяется РОВНО один раз, failed migration → exit 1 # (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp. - # NB: цикл берёт только *.sql — data/sql/_manifest_applied.txt (инвариант - # #2216) glob'ом не подхватывается. + # ИМЕННО ЭТОТ цикл делает main эталоном применённого: всё, что доехало + # до main, здесь и применяется, а имя закрепляется в _schema_migrations. + # На этом стоит гейт номеров — tests/test_migration_numbering.py (#2683). # Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это # первый deploy после внедрения tracking на уже-наполненной prod-БД diff --git a/backend/tests/sql/test_auth_sql_migrations.py b/backend/tests/sql/test_auth_sql_migrations.py index 3c02f7b9..29a14105 100644 --- a/backend/tests/sql/test_auth_sql_migrations.py +++ b/backend/tests/sql/test_auth_sql_migrations.py @@ -1,11 +1,11 @@ """Инварианты миграций БД `auth` (data/sql/auth/*.sql) + её bootstrap (ops/db-bootstrap/*.sql). -Прецедента manifest-теста для КОРНЕВОГО data/sql в этом репозитории нет (он есть только -в tradein: tradein-mvp/backend/tests/test_migrations_manifest.py по -tradein-mvp/backend/data/sql/_manifest_applied.txt). Заводить manifest на 154 legacy-файла -корневого каталога — не задача этого PR, поэтому здесь проверяются инварианты, которые -можно проверить БЕЗ снимка «уже применённого»: они выполнимы на новом каталоге с первого -дня и ловят регрессии, которые иначе всплывают только на проде во время деплоя. +Снимка «уже применённого» здесь нет и не нужно: у соседнего стека такой файл-список был +(tradein data/sql/_manifest_applied.txt) и его удалили в #2683 — он отставал от каталога +и по построению не мог покраснеть. Аналог гейта для tradein теперь берёт эталон из git: +tradein-mvp/backend/tests/test_migration_numbering.py. Здесь же проверяются инварианты, +выполнимые БЕЗ всякого эталона: они верны на новом каталоге с первого дня и ловят +регрессии, которые иначе всплывают только на проде во время деплоя. Тест не требует БД — только чтение файлов. """ diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt deleted file mode 100644 index bbb3f066..00000000 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ /dev/null @@ -1,243 +0,0 @@ -# _manifest_applied.txt — CONTRACT (issue #2216) -# -# Отсортированный список ВСЕХ имён миграций (bare filename) в data/sql/, -# которые на момент коммита уже применены/забейслайнены на проде. -# Прод трекает миграции по bare-filename в public._schema_migrations — -# переименование или удаление применённого файла => повторный прогон на -# проде (новый filename считается неприменённым) => дубль-эффекты/ошибки. -# -# ПРАВИЛА (enforced tests/test_migrations_manifest.py): -# 1. Каждое имя здесь ОБЯЗАНО существовать в data/sql/ (нельзя rename/rm applied). -# 2. Новый .sql-файл => НЕ переиспользуй NN-префикс (кроме 6 grandfathered дублей). -# 3. Добавляя новую миграцию — допиши её имя сюда В ТОМ ЖЕ PR (список отсортирован). -# -# Комментарии (# ...) и пустые строки тест игнорирует. -001_trade_in_estimates.sql -002_core_tables.sql -003_seed_deals.sql -004_extend_trade_in_estimates.sql -005_geocode_tracking.sql -007_estimate_photos.sql -008_crm_fields.sql -009_houses.sql -010_houses_alter.sql -011_listings_alter.sql -012_sellers.sql -013_listings_alter_seller.sql -014_house_reviews.sql -015_scrape_runs.sql -016_listings_snapshots.sql -017_house_placement_history.sql -018_avito_imv_evaluations.sql -019_listings_alter_cian.sql -020_houses_alter_cian.sql -021_management_companies.sql -022_agents_table.sql -023_offer_price_history.sql -024_houses_price_dynamics.sql -025_house_reliability_checks.sql -026_external_valuations.sql -027_cian_session_cookies.sql -028_matching_tables.sql -029_extend_matching_valuation_dynamics.sql -030_avito_imv_cache_key_unique.sql -031_houses_alter_yandex.sql -032_yandex_history.sql -033_listings_alter_yandex.sql -034_trade_in_estimates_geom.sql -035_drop_duplicate_indexes.sql -040_houses_extend.sql -041_house_sources_noop.sql -042_listing_sources_price_divergence_idx.sql -043_house_reviews_extend.sql -044_external_valuations_link.sql -045_house_placement_history_extend.sql -046_views.sql -047_cian_history_sanitize.sql -050_search_optimization.sql -051_scrape_runs_extend.sql -052_scrape_schedules.sql -053_scraper_settings.sql -054_scraper_settings_global.sql -060_postgres_fdw_extension.sql -061_drop_legacy_cad_buildings.sql -062_clean_avito_addresses.sql -063_backfill_houses_and_link_listings.sql -064_house_imv_phase_c.sql -065_trade_in_estimates_floor_optional.sql -066_address_mismatch_audit.sql -067_v_street_sales_vs_listings.sql -068_drop_v_street_sales_vs_listings.sql -069_trade_in_estimates_dadata_fields.sql -070_houses_dadata_enrichment.sql -071_houses_cian_zhk_url.sql -072_scrape_schedules_seed_cian_rosreestr.sql -073_normalize_repair_state.sql -075_backfill_repair_state_from_description.sql -076_account_estimate_quota.sql -077_dedup_hash_plain_key_backfill.sql -078_scrape_schedules_seed_yandex_sweep.sql -079_listing_source_history.sql -080_asking_to_sold_ratios.sql -081_trade_in_estimates_expected_sold.sql -082_scrape_schedules_seed_ratio_refresh.sql -083_trade_in_estimates_created_by.sql -084_brand_praktika_fill.sql -084_scrape_schedules_seed_n1_sweep.sql -085_quarter_price_index_fdw.sql -086_deals_address_trgm_index.sql -087_fdw_server_options.sql -088_scrape_schedules_seed_search_matview_refresh.sql -089_listings_geo_precision.sql -090_scrape_schedules_seed_deactivate_stale_avito.sql -091_scrape_schedules_seed_yandex_address_backfill.sql -092_sber_price_index.sql -093_scrape_schedules_seed_sber_index_pull.sql -094_cadastral_unify.sql -095_dead_schema.sql -096_scrape_schedules_seed_rosreestr_quarter_poll.sql -097_index_hygiene.sql -098_asking_to_sold_ratios_tiered.sql -099_brand_praktika_logo_wordmark.sql -100_enable_deactivate_stale_avito.sql -101_gendesign_reader_role.sql -102_grant_listings_gendesign_reader.sql -103_scrape_schedules_seed_newbuilding_enrich.sql -104_index_hygiene_geom_dedup.sql -105_market_schema_yandex_enrichment.sql -106_scrape_schedules_seed_yandex_newbuilding_sweep.sql -107_scrape_schedules_seed_cian_city_sweep.sql -108_clean_avito_addresses_v2.sql -108_merge_duplicate_houses.sql -109_asking_to_sold_ratio_segment_filter.sql -110_scrape_schedules_seed_geocode_missing_listings.sql -111_listings_avito_detail_fields.sql -112_scrape_schedules_seed_avito_detail_backfill.sql -113_deactivate_ghost_duplicate_listings.sql -113_yandex_detail_backfill.sql -114_disable_n1_sweep.sql -115_scrape_schedules_seed_deactivate_stale_yandex_cian.sql -116_offer_price_history_change_trigger.sql -117_listings_last_seen_deactivate_index.sql -118_enable_cian_city_sweep.sql -119_yandex_city_sweep_center_combos.sql -120_restore_partial_active_indexes.sql -121_remove_brand_praktika.sql -121_yandex_rich_fields.sql -122_enable_domclick_city_sweep.sql -123_avito_newbuilding_sweep_schedule.sql -124_cad_buildings_local.sql -124_deglue_avito_addresses.sql -125_scrape_schedules_seed_cadastral_geo_match.sql -126_scrape_schedules_seed_cian_full_load.sql -127_scrape_schedules_seed_avito_full_load.sql -128_listings_card_hash.sql -129_avito_full_load_incremental_split.sql -130_backfill_listings_house_id_fk.sql -130_ekb_geoportal_buildings.sql -131_fix_diff_percent_overflow.sql -132_scrape_schedules_seed_house_imv.sql -133_listings_uq_source_source_id.sql -134_listings_geom_geography_gist.sql -135_scrape_schedules_seed_house_dedup_merge.sql -136_backfill_listings_house_id_fk_source_identity.sql -137_listings_addr_norm_trgm.sql -138_domclick_bff_rewrite_schedule.sql -139_premium_houses.sql -140_yandex_house_type_backfill.sql -141_cian_promote_house_type.sql -142_premium_buildings_curated.sql -143_building_sale_share_schema.sql -144_gar_canon_addr_match.sql -145_building_sale_share_plausible_denom.sql -146_sale_share_45d_and_zhkh_denom.sql -147_canon_strip_geo_prefixes.sql -148_dedup_apartments_in_sale_share.sql -149_zhkh_priority_denominator.sql -150_sale_share_listing_geo_filter.sql -151_clean_bare_street_aliases.sql -152_sale_share_floors_guard.sql -153_sale_share_listings_floors_plausibility.sql -154_market_contract_views.sql -155_reader_grants_to_contract_views.sql -156_revoke_raw_from_reader.sql -157_scrape_proxies.sql -158_seed_proxy_healthcheck_schedule.sql -159_houses_fias_idx.sql -160_seed_deactivate_stale_domklik_n1.sql -161_backfill_scraped_at_active_recent.sql -162_seed_deals_freshness_monitor.sql -163_disable_deactivate_stale_domklik.sql -164_yandex_url_canonicalize_active_dups.sql -165_remove_n1_source.sql -166_purge_listings_phones.sql -167_drop_client_pii.sql -168_fdw_osm_poi_ekb.sql -169_osm_poi_ekb_local.sql -170_scrape_schedules_seed_osm_poi_ekb_refresh.sql -171_scrape_schedules_seed_geoportal_coords_backfill.sql -172_trade_in_leads.sql -173_scrape_proxies_add_domclick_affinity.sql -174_domclick_session_cookies.sql -175_scrape_schedules_seed_domclick_detail_backfill.sql -176_domrf_kapremont.sql -177_deals_city_region.sql -178_deal_city_price_bands.sql -179_scrape_schedules_seed_oblast_city_sweeps.sql -180_seed_sber_freshness_monitor.sql -181_clamp_bad_listing_dates.sql -182_trade_in_leads_consent_proof.sql -183_reenable_deactivate_stale_domklik.sql -184_user_events.sql -185_account_quota_overrides.sql -186_tg_support.sql -187_web_support_chat.sql -188_tg_support_chat_id_scope.sql -189_account_estimate_usage_nonnegative.sql -190_sale_share_price_bucket_signature.sql -191_account_quota_unlimited_flag.sql -192_tradein_users_auth.sql -193_tradein_users_seed.sql -194_deal_city_price_bands_tiers.sql -195_scrape_schedules_seed_deal_city_price_bands_refresh.sql -196_listings_city.sql -197_backfill_listings_city_from_url.sql -198_scrape_proxy_rotations.sql -199_scrape_proxies_asocks_rotate_url.sql -200_region_code_foreign_cities.sql -201_purge_dead_mobileproxy_proxies.sql -202_listing_source_snapshot_budget_sec.sql -203_purge_geocode_cache_house_letter.sql -204_cian_oblast_sweeps_secondary.sql -205_sales_vs_listings_city_filter.sql -206_scrape_schedules_cut_wasteful_load.sql -207_backfill_yandex_cian_city_geo_cleanup.sql -208_reenable_domclick_detail_backfill.sql -209_scrape_proxies_disabled_reason.sql -210_scrape_proxy_source_bans.sql -211_sales_vs_listings_segment_guard.sql -212_sber_index_pull_weekly.sql -213_listings_snapshots_status_vocab.sql -214_drop_dead_run_metrics.sql -215_avito_full_load_window_matches_cadence.sql -216_dead_code_sweep.sql -# -# 2026-08-06: список догнан до факта прода. Проверка перед правкой — -# _schema_migrations на tradein-postgres: 209 применённых имён, здесь было -# 178; расхождение — 31 имя, все в одну сторону (применено, но не заморожено). -# Обратного расхождения нет: ни одной строки, которой не было бы на проде. -# -# Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат -# поддержки, #2532/#2533) откладывались до подтверждения, что они осели на -# проде в финальном виде. Они в _schema_migrations — условие выполнено. -# -# 217-232 сюда намеренно не дописаны этой миграцией (222/225): в момент -# правки они уже слиты в main и применены на проде (см. _schema_migrations), -# но их авторы не дописали имена в тот же PR — это чужой пробел, не наш; -# self-maintenance-контракт (см. докстринг test_migrations_manifest.py) -# требует дописывать только СВОЙ файл в СВОЁМ PR, что и сделано ниже для -# 222/225 по прецеденту 233_payments.sql. -222_db_audit_cleanup.sql -225_listing_source_snapshots_run_id_idx.sql -233_payments.sql -234_scrape_runs_ban_kind_unknown.sql diff --git a/tradein-mvp/backend/tests/skip_allowlist.txt b/tradein-mvp/backend/tests/skip_allowlist.txt index f7b8acbd..5bea8459 100644 --- a/tradein-mvp/backend/tests/skip_allowlist.txt +++ b/tradein-mvp/backend/tests/skip_allowlist.txt @@ -52,3 +52,12 @@ tests/test_purge_expired_trade_in_data.py::test_real_purge_deletes_only_anonymou # дефолт mark_banned ('unknown') проходит CHECK-констрейнт из миграции 234: # на мок-лэйне (deploy-tradein.yml, DSN-заглушка) констрейнта нет вовсе. tests/test_2764_ban_kind_no_default.py::test_real_default_ban_kind_survives_the_check_constraint + +# Гейт номеров миграций (#2683) сверяется с origin/main и точкой ветвления. Где +# git-эталона нет — прогон внутри prod-образа, экспорт исходников без .git — +# проверять не с чем, и тест это ГОВОРИТ вслух вместо тихого зелёного. +# В CI пропуска не бывает: при CI/GITHUB_ACTIONS та же ветка делает pytest.fail +# (отсутствие эталона в пайплайне — сломанный гейт, а не «нечего проверять»), +# а ci-tradein.yml/deploy-tradein.yml тянут main отдельным шагом. +tests/test_migration_numbering.py::test_applied_migration_is_not_renamed_or_deleted +tests/test_migration_numbering.py::test_new_migration_takes_a_free_number diff --git a/tradein-mvp/backend/tests/test_migration_numbering.py b/tradein-mvp/backend/tests/test_migration_numbering.py new file mode 100644 index 00000000..a97ebcc8 --- /dev/null +++ b/tradein-mvp/backend/tests/test_migration_numbering.py @@ -0,0 +1,215 @@ +"""Инварианты нумерации миграций trade-in (issues #2216, #2683). + +КОНТРАКТ. ОДНА ФОРМУЛИРОВКА, И ОНА ЗДЕСЬ — больше нигде её дублировать не надо. + + Эталон «что уже закреплено на проде» — origin/main, а не файл-список. + deploy-tradein.yml прогоняет КАЖДЫЙ data/sql/*.sql из main под ON_ERROR_STOP + (падение миграции => красный деплой) и трекает применённое по bare filename в + public._schema_migrations. То есть «файл доехал до main» == «имя закреплено на + проде», и вести это знание отдельно от git незачем: git и есть журнал. + + Отсюда ровно два инварианта, и ниже проверяются именно они. + + 1. Имя, существовавшее в точке ветвления, нельзя переименовать или удалить. + Прод помнит СТАРОЕ имя; новое считается неприменённым и прогоняется + повторно — дубль-INSERT / повторный DDL / PK violation под ON_ERROR_STOP, + то есть либо красный деплой, либо тихо задвоенные данные. Нужно изменить + уже применённую миграцию — заводи НОВЫЙ файл, старый оставь как есть. + + 2. НОВЫЙ файл обязан нести NN-префикс, свободный не только в рабочем дереве, + но и в origin/main. Порядок применения — `ls | sort`, два файла с одним NN + дают неопределённый порядок. Шесть исторических дублей (084/108/113/121/ + 124/130) не «новые» и не флагаются. + + ДОПИСЫВАТЬ НИЧЕГО НЕ НАДО. Автор миграции кладёт файл со свободным номером — и + всё. Списка, который можно забыть обновить, здесь больше нет: до #2683 таким + списком был data/sql/_manifest_applied.txt, и он по построению не мог + покраснеть — «файл, которого нет в списке» и «новый файл этого PR» были для + теста одним и тем же, поэтому забытое имя навсегда оставалось зелёным + (замер 2026-08-07: 15 забытых имён, сьют зелёный). + + ПОЧЕМУ ТОЧКА ВЕТВЛЕНИЯ, А НЕ САМ origin/main. Ветка, отведённая неделю назад, + не содержит миграций, смерженных после неё. Правило «origin/main ⊆ рабочее + дерево» красило бы каждую такую ветку без вины автора — и его отключили бы + через неделю. Сверка с merge-base ловит ровно то, что удалила или + переименовала ЭТА ветка, а номера при этом сверяются с ПОЛНЫМ origin/main, + чтобы коллизия с миграцией, смерженной после ветвления, всё-таки нашлась. + + КАК УБЕДИТЬСЯ, ЧТО СТОРОЖ УМЕЕТ КРАСНЕТЬ (не на слово): + test_collision_rule_flags_a_taken_number ниже проверяет само правило на + литеральных входах, а сквозной прогон воспроизводится так — + git worktree add --detach /tmp/wt + touch /tmp/wt/tradein-mvp/backend/data/sql/<занятый-NN>_probe.sql + (cd /tmp/wt/tradein-mvp/backend && pytest tests/test_migration_numbering.py) +""" + +from __future__ import annotations + +import os +import re +import subprocess +from collections.abc import Iterable +from pathlib import Path, PurePosixPath + +import pytest + +_TESTS_DIR = Path(__file__).resolve().parent +_SQL_DIR = _TESTS_DIR.parent / "data" / "sql" +_REPO_ROOT = _TESTS_DIR.parents[2] +# Путь каталога ОТ КОРНЯ РЕПО — им адресуем дерево коммита через git ls-tree. +_SQL_PATHSPEC = "tradein-mvp/backend/data/sql" + +_NN_PREFIX = re.compile(r"^(\d+)_") +# origin — штатный remote; forgejo/main оставлен как исторический алиас. +_MAIN_REFS = ("origin/main", "forgejo/main", "main") + + +def _git(*args: str) -> str | None: + """stdout git-команды, либо None если git недоступен/команда упала.""" + try: + done = subprocess.run( + # Фиксированный argv, без shell — args приходят только из этого модуля. + ["git", "-C", str(_REPO_ROOT), *args], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + return done.stdout if done.returncode == 0 else None + + +def _sql_names_at(rev: str) -> set[str]: + """Bare-имена *.sql в data/sql на ревизии rev.""" + out = _git("ls-tree", "-r", "-z", "--name-only", rev, "--", _SQL_PATHSPEC) or "" + return {PurePosixPath(p).name for p in out.split("\0") if p.endswith(".sql")} + + +def _sql_names_on_disk() -> set[str]: + """Bare-имена *.sql в рабочем дереве — включая ещё не закоммиченные.""" + return {p.name for p in _SQL_DIR.glob("*.sql")} + + +def _prefix(name: str) -> str | None: + m = _NN_PREFIX.match(name) + return m.group(1) if m else None + + +def _collisions(new_names: Iterable[str], universe: Iterable[str]) -> list[str]: + """Для каждого НОВОГО имени — чужие имена с тем же NN-префиксом.""" + by_prefix: dict[str, set[str]] = {} + for name in universe: + p = _prefix(name) + if p is not None: + by_prefix.setdefault(p, set()).add(name) + + found: list[str] = [] + for name in sorted(new_names): + p = _prefix(name) + if p is None: + continue + others = sorted(by_prefix.get(p, set()) - {name}) + if others: + found.append(f"{name} — номер {p} уже занят: {', '.join(others)}") + return found + + +def _baseline() -> tuple[set[str], set[str]]: + """(имена в точке ветвления, имена в main). Без git-эталона проверять нечего.""" + main_ref = next( + (r for r in _MAIN_REFS if _git("rev-parse", "--verify", "--quiet", f"{r}^{{commit}}")), + None, + ) + merge_base = None + if main_ref is not None: + out = _git("merge-base", main_ref, "HEAD") + merge_base = out.strip() if out else None + + if main_ref is None or merge_base is None: + why = ( + f"нет git-эталона миграций: ни один из {_MAIN_REFS} не резолвится либо у него " + f"нет общего предка с HEAD (repo={_REPO_ROOT}). Лечится " + "`git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main` " + "и НЕ shallow-клоном (нужен общий предок)." + ) + # В CI это не «нечего проверять», а сломанный гейт: пропуск здесь и есть + # тот зелёный, который ничего не проверяет. Поэтому красим. + if os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"): + pytest.fail(why) + pytest.skip(why) + + base_names = _sql_names_at(merge_base) + main_names = _sql_names_at(main_ref) + # Анти-вакуум: пустой эталон сделал бы обе проверки зелёными всегда. + # Ровно так ломается сторож, если _SQL_PATHSPEC разъедется с раскладкой репо. + assert base_names, ( + f"эталон пуст: git ls-tree {merge_base} -- {_SQL_PATHSPEC} не вернул ни одного " + "*.sql. Проверка номеров была бы вакуумно-зелёной — почини путь." + ) + assert main_names, f"в {main_ref} не найдено *.sql по пути {_SQL_PATHSPEC} — то же самое." + return base_names, main_names + + +def test_applied_migration_is_not_renamed_or_deleted() -> None: + """Файл, существовавший в точке ветвления, обязан существовать и сейчас. + + Red => эта ветка переименовала или удалила миграцию, которую прод уже + применил и помнит по СТАРОМУ имени. Верни исходное имя; нужно поправить + поведение — заводи новый файл с новым номером. + """ + on_disk = _sql_names_on_disk() + assert on_disk, f"не найдено *.sql в {_SQL_DIR}" + base_names, _ = _baseline() + + gone = sorted(base_names - on_disk) + assert not gone, ( + f"миграции пропали из {_SQL_PATHSPEC}/ (переименованы или удалены): {gone}. " + "Прод трекает их по bare-filename в _schema_migrations — под новым именем " + "миграция прогонится повторно. Верни имена как были." + ) + + +def test_new_migration_takes_a_free_number() -> None: + """Новый файл не переиспользует NN, занятый в origin/main или в этой ветке. + + Red => номер уже занят. Возьми следующий свободный, сверяясь с origin/main: + + git fetch origin main + git ls-tree -r --name-only origin/main -- tradein-mvp/backend/data/sql | tail + + `-r` обязателен: без него ls-tree печатает сам каталог, а не файлы (в этом + виде рецепт и ходил по issue #2683 — и молча возвращал одну строку). + Локального `ls` недостаточно: он не видит миграций, смерженных после + ветвления — ровно так разъехались 212 в #2682 и 234 в #2754. + """ + on_disk = _sql_names_on_disk() + base_names, main_names = _baseline() + + new_names = on_disk - base_names + problems = _collisions(new_names, main_names | on_disk) + assert not problems, "коллизия номеров миграций: " + "; ".join(problems) + + +def test_collision_rule_flags_a_taken_number() -> None: + """Проверка самого правила — сторож обязан уметь краснеть (#2683 п.6). + + Ожидания здесь — ЛИТЕРАЛЫ, а не производные от содержимого data/sql: тест, + который берёт ожидание из охраняемой настройки, зелен при любой настройке. + """ + # Реальный случай #2754: ветка отвелась до того, как в main приехал 234_scrape. + assert _collisions( + ["234_trade_in_estimates_retain_until.sql"], + { + "233_payments.sql", + "234_scrape_runs_ban_kind_unknown.sql", + "234_trade_in_estimates_retain_until.sql", + }, + ) == [ + "234_trade_in_estimates_retain_until.sql — номер 234 уже занят: " + "234_scrape_runs_ban_kind_unknown.sql" + ] + # Два новых файла с одним номером внутри одной ветки — оба названы. + assert len(_collisions(["300_a.sql", "300_b.sql"], {"300_a.sql", "300_b.sql"})) == 2 + # Свободный номер — тишина; исторические дубли не новые и не флагаются. + assert _collisions(["300_a.sql"], {"084_x.sql", "084_y.sql", "300_a.sql"}) == [] diff --git a/tradein-mvp/backend/tests/test_migrations_manifest.py b/tradein-mvp/backend/tests/test_migrations_manifest.py deleted file mode 100644 index 26e3375f..00000000 --- a/tradein-mvp/backend/tests/test_migrations_manifest.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Invariants over data/sql migrations (issue #2216). - -Прод применяет миграции по BARE FILENAME: deploy-tradein.yml трекает каждый -`data/sql/*.sql` в таблице `public._schema_migrations` (PRIMARY KEY = filename). -Из этого следуют два хрупких инварианта, которые этот тест защищает от регрессии: - -1. Переименование / удаление УЖЕ ПРИМЕНЁННОЙ миграции ломает прод: новый - filename считается неприменённым и прогоняется повторно (дубль-INSERT, - повторный DDL, PK violation под ON_ERROR_STOP => деплой падает или, хуже, - молча дублирует данные). Manifest `data/sql/_manifest_applied.txt` — это - слепок применённых имён на момент коммита; любой из них ОБЯЗАН существовать. - -2. Два разных файла с одинаковым NN-префиксом ("дубль-префикс") — источник - двусмысленного порядка применения (`ls | sort` детерминирован, но человек - легко создаёт коллизию). 6 исторических дублей grandfathered'ы (оба в - manifest). Любой НОВЫЙ файл обязан нести уникальный префикс. - -Self-maintenance: добавляя новую миграцию, допиши её имя в manifest В ТОМ ЖЕ PR -(см. assert-сообщения ниже). Тест требует data/sql ⊇ manifest и уникальность -префикса у новых файлов; сам manifest дополняет автор миграции. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -_BACKEND_ROOT = Path(__file__).resolve().parents[1] -_SQL_DIR = _BACKEND_ROOT / "data" / "sql" -_MANIFEST = _SQL_DIR / "_manifest_applied.txt" - -_NN_PREFIX = re.compile(r"^(\d+)_") - - -def _read_manifest() -> list[str]: - """Имена миграций из manifest; # comments и пустые строки игнорируются.""" - names: list[str] = [] - for raw in _MANIFEST.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - names.append(line) - return names - - -def _actual_sql_files() -> set[str]: - return {p.name for p in _SQL_DIR.glob("*.sql")} - - -def _prefix(name: str) -> str | None: - m = _NN_PREFIX.match(name) - return m.group(1) if m else None - - -def test_manifest_entries_all_exist() -> None: - """Каждый файл из manifest СУЩЕСТВУЕТ в data/sql/. - - Red => применённая миграция переименована или удалена. Прод трекает по - bare-filename: старое имя остаётся в _schema_migrations, НОВОЕ имя считается - неприменённым и прогоняется повторно. Восстанови исходное имя файла (или, - если переименование намеренное — так делать НЕЛЬЗЯ для уже-применённых - миграций: заведи НОВЫЙ файл, а старый оставь как есть). - """ - actual = _actual_sql_files() - manifest = _read_manifest() - missing = sorted(n for n in manifest if n not in actual) - assert not missing, ( - "Миграции из _manifest_applied.txt отсутствуют в data/sql/ " - f"(переименованы/удалены?): {missing}. Эти имена уже применены на проде " - "(tracking по bare-filename в _schema_migrations) — их нельзя " - "переименовывать/удалять. Верни исходные имена файлов." - ) - - -def test_manifest_is_sorted_and_unique() -> None: - """Manifest отсортирован (codepoint) и без дублей — детерминированный слепок.""" - manifest = _read_manifest() - assert manifest == sorted(manifest), ( - "_manifest_applied.txt не отсортирован. Пересортируй записи " - "(LC_ALL=C sort / Python sorted())." - ) - dupes = sorted({n for n in manifest if manifest.count(n) > 1}) - assert not dupes, f"Дублирующиеся строки в _manifest_applied.txt: {dupes}" - - -def test_new_files_do_not_reuse_prefix() -> None: - """Новые (не в manifest) .sql-файлы НЕ переиспользуют существующий NN-префикс. - - Grandfathered дубли (084/108/113/121/124/130) — оба файла в manifest, поэтому - не флагаются: считаются "существующими", а не "новыми". - - Red => новый файл взял префикс уже присутствующей миграции. Присвой - следующий свободный NN и допиши имя в _manifest_applied.txt (тот же PR). - """ - actual = _actual_sql_files() - manifest = set(_read_manifest()) - - # Префиксы, «занятые» уже-применёнными (manifest) миграциями. - baseline_prefixes: set[str] = set() - for name in manifest: - p = _prefix(name) - if p is not None: - baseline_prefixes.add(p) - - new_files = sorted(actual - manifest) - collisions: list[str] = [] - # Внутри новых файлов префикс тоже обязан быть уникален (два новых с одним NN). - seen_new_prefix: dict[str, str] = {} - for name in new_files: - p = _prefix(name) - if p is None: - continue - if p in baseline_prefixes: - collisions.append(f"{name} (префикс {p} занят применённой миграцией)") - elif p in seen_new_prefix: - collisions.append(f"{name} (префикс {p} уже у нового {seen_new_prefix[p]})") - else: - seen_new_prefix[p] = name - - assert not collisions, ( - "Новые миграции переиспользуют NN-префикс: " + "; ".join(collisions) + ". " - "Присвой следующий свободный номер и добавь имя файла в " - "_manifest_applied.txt в ЭТОМ ЖЕ PR." - ) - - -def test_manifest_covers_all_but_new_files() -> None: - """data/sql ⊇ manifest, и каждый новый файл имеет уникальный префикс — - напоминание о self-maintenance: manifest дополняется вместе с миграцией. - - Этот тест НЕ требует, чтобы новый файл уже был в manifest (иначе PR с новой - миграцией всегда красный). Он лишь гарантирует, что manifest не отстал от - реальности В ЧАСТИ применённых имён (см. test_manifest_entries_all_exist) и - что новые файлы не создают префикс-коллизий (см. предыдущий тест). - """ - # Sanity: manifest непустой и в data/sql есть файлы — защита от битых путей. - assert _actual_sql_files(), f"Не найдено *.sql в {_SQL_DIR}" - assert _read_manifest(), f"_manifest_applied.txt пуст: {_MANIFEST}"