gendesign/tradein-mvp/backend/tests/test_2857_search_mv_placeholder_columns.py
bot-backend 05d2126c5a
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m32s
Deploy Trade-In / build-backend (push) Successful in 34s
Deploy Trade-In / deploy (push) Successful in 1m50s
chore(tradein/db): убрать три колонки-заглушки витрины поиска (#2857) (#2858)
2026-08-13 07:25:43 +00:00

145 lines
7.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Витрина поиска не обещает колонок, которых не заполняет (#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 без него невозможен."
)