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