"""Витрина поиска не обещает колонок, которых не заполняет (#2857, эпик #2674). `listings_search_mv` с 050 несла четыре колонки, заданные литералом `NULL` прямо в определении: district, distance_to_metro_m, last_price_change, photos_count. Это не потеря данных и не оборванный писатель — имена зарезервировали, реализацию не подключили никогда. Три из них не читает НИКТО (ни бэкенд, ни фронт, ни тесты) и они сняты миграцией 261; district оставлен намеренно — он объявлен в schemas/search_response.py, то есть API его отдаёт, и его снос это ломающее изменение контракта (решение владельца, вынесено отдельно в #2857). Проверяется ФАКТ, а не текст: тест собирает СПИСОК КОЛОНОК витрины разбором её актуального определения (самый старший NN среди файлов, создающих витрину) и смотрит на состав списка. Переформатирование SQL, перестановка строк или смена `NULL::int` на `NULL::integer` тест не трогают; возврат колонки — краснит. Без БД и сети: миграции читаются как текст, разбираются в структуру. """ from __future__ import annotations import os import re from pathlib import Path os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql" MV = "listings_search_mv" # Сняты 261: ноль читателей во всём репозитории на момент сноса. DROPPED = ("distance_to_metro_m", "last_price_change", "photos_count") def _strip_sql_comments(sql: str) -> str: sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL) return re.sub(r"--[^\n]*", "", sql) def _latest_definition() -> str: """Текст файла с самым старшим NN, который создаёт витрину = её актуальный вид.""" creators = [ p for p in SQL_DIR.glob("*.sql") if re.search( rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\b", _strip_sql_comments(p.read_text("utf-8")), re.I, ) ] assert creators, f"ни одна миграция не создаёт {MV} — тест смотрит не туда" return max(creators, key=lambda p: int(p.name.split("_", 1)[0])).read_text("utf-8") def mv_columns() -> list[str]: """Имена колонок витрины в порядке объявления. Разбор: от `AS SELECT` до `FROM` на нулевой глубине скобок, разрез по запятым той же глубины, имя колонки — последний идентификатор элемента (алиас после `AS` либо хвост `l.foo`). """ sql = _strip_sql_comments(_latest_definition()) body = re.split(rf"CREATE\s+MATERIALIZED\s+VIEW\s+{MV}\s+AS\s+SELECT\b", sql, flags=re.I)[1] depth, items, cur = 0, [], [] for token in re.finditer(r"\(|\)|,|\bFROM\b|[^(),]+", body, re.I): t = token.group(0) if t == "(": depth += 1 elif t == ")": depth -= 1 elif depth == 0 and t == ",": items.append("".join(cur)) cur = [] continue elif depth == 0 and t.upper() == "FROM": break cur.append(t) items.append("".join(cur)) return [item.split()[-1].split(".")[-1] for item in items if item.split()] def test_placeholder_columns_are_gone_from_the_matview() -> None: """Red => витрина снова обещает поля, которых не заполняет (#2857). Три колонки были литеральным `NULL` без единого читателя. Если тест покраснел после возврата колонки — сначала заведи писателя, потом колонку, а не наоборот. """ cols = mv_columns() still_there = [c for c in DROPPED if c in cols] assert not still_there, ( f"{MV} снова отдаёт колонки-заглушки {still_there}. Колонка без писателя " "читается снаружи как «данные есть, просто у этого объекта пусто» — это " "хуже мёртвого кода, потому что видно в контракте." ) def test_district_is_deliberately_kept() -> None: """Red => district снесли заодно, а он в схеме ответа API. schemas/search_response.py объявляет `district: str | None`, services/search_query.py его тянет — снос ломает контракт /api/v1/search. Это решение владельца (#2857), а не побочный эффект уборки соседних заглушек. Убирать — вместе со схемой ответа. """ assert "district" in mv_columns(), ( f"district пропал из {MV}, а schemas/search_response.py его всё ещё объявляет: " "ответ поиска начнёт падать/врать. Снимать поле — только вместе со схемой." ) def test_search_api_selects_only_columns_the_matview_has() -> None: """Настоящий инвариант: то, что просит API, витрина обязана иметь. Именно эта проверка отличает «список колонок» от «поиска подстроки»: она краснеет на ЛЮБОЙ колонке, снесённой без правки читателя, а не только на трёх известных именах. """ from app.schemas.search import SearchParams from app.services.search_query import build_search_query sql, _ = build_search_query(SearchParams()) selected = [ c.strip() for c in sql[len("SELECT ") : sql.index(f" FROM {MV}")].split(",") if c.strip() ] missing = [c for c in selected if c not in mv_columns()] assert not missing, ( f"services/search_query.py просит у {MV} колонки, которых в её определении нет: " f"{missing}. Либо верни колонку в витрину, либо убери её из запроса И из " "schemas/search_response.py." ) def test_unique_index_for_concurrent_refresh_survives_recreation() -> None: """Red => ночной REFRESH ... CONCURRENTLY упадёт. app/tasks/refresh_search_matview.py рефрешит витрину CONCURRENTLY (расписание refresh_search_matview, 03:00-04:00 UTC). Без UNIQUE-индекса PostgreSQL отвечает «cannot refresh materialized view concurrently ... no unique index» — а витрина, которую пересоздали и забыли проиндексировать, молчит до самой ночи. """ sql = _strip_sql_comments(_latest_definition()) assert re.search(rf"CREATE\s+UNIQUE\s+INDEX[^;]+ON\s+{MV}\s*\(\s*listing_id\s*\)", sql, re.I), ( f"в актуальном определении {MV} нет UNIQUE-индекса по listing_id — " "REFRESH MATERIALIZED VIEW CONCURRENTLY без него невозможен." )