All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
199 lines
12 KiB
Python
199 lines
12 KiB
Python
"""Помощники для тестов, зависящих от того, В КАКОМ РЕЕСТРЕ живут люди.
|
||
|
||
Эпик «единый вход»: `settings.identity_store` переключает код «Меры» между
|
||
`tradein_users`/`tradein_sessions` (БД tradein — ДЕФОЛТ, сегодняшнее поведение
|
||
прода) и `users`/`sessions` (БД `auth`). Различаются имена таблиц И тип колонки
|
||
состояния доступа (`is_active boolean` против `access_state text`).
|
||
|
||
⚠️ ЗАЧЕМ ЭТОТ МОДУЛЬ (главная ловушка этих тестов). Интеграционные тесты
|
||
`test_auth_api.py` / `test_team_api.py` используют fake-DB, который диспатчит по
|
||
ТЕКСТУ SQL. Если ветка такого fake'а сравнивает с литералом «tradein_users», то
|
||
при `identity_store="auth"` она просто перестаёт матчиться — fake вернёт пустой
|
||
результат вместо строки, а тест останется ЗЕЛЁНЫМ на сломанном коде. Поэтому:
|
||
|
||
* имена для матчинга берутся из `identity_schema()` (`sql_names()` ниже) —
|
||
ровно оттуда же, откуда их берёт продакшн-код;
|
||
* непонятый SQL в fake'ах ОБЯЗАН падать `AssertionError`, а не возвращать
|
||
пустоту (см. `raise AssertionError(f"unhandled fake SQL ...")` в обоих
|
||
файлах) — это то, что превращает «ветка отвалилась» в красный тест.
|
||
|
||
`column_value()` — намеренно ЛИТЕРАЛЬНАЯ таблица «состояние → значение
|
||
колонки», а НЕ вызов `identity_store.access_state_param()`. Fake обязан хранить
|
||
то, что реально лежало бы в Postgres; если бы он звал ту же production-функцию,
|
||
что и проверяемый код, её инверсия (`active` ↔ `disabled`) прошла бы round-trip
|
||
через fake незамеченной, и тест бы не покраснел.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from collections.abc import Callable, Iterator
|
||
from contextlib import contextmanager
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
import pytest
|
||
|
||
from app.core import auth_db, config
|
||
from app.services import identity_store
|
||
from app.services.identity_store import AccessState, identity_schema
|
||
|
||
# Оба допустимых значения `IDENTITY_STORE` (Literal в pydantic-настройках).
|
||
# "tradein" ПЕРВЫЙ — это дефолт и путь прода; при чтении вывода pytest'а первый
|
||
# параметр всегда «как сейчас», второй — «после переезда».
|
||
IDENTITY_MODES = ("tradein", "auth")
|
||
|
||
# Состояние доступа → значение, которое реально лежит в колонке реестра.
|
||
# Литералы, независимые от production-кода (см. модульный docstring).
|
||
# `trial_expired` в булевой схеме ОТСУТСТВУЕТ: состояния «пробный период истёк»
|
||
# там не существовало, выразить его нечем — тесты про него имеют смысл только в
|
||
# режиме `auth`, поэтому здесь явная ошибка вместо тихого приведения к False.
|
||
_COLUMN_VALUE: dict[tuple[str, AccessState], bool | str] = {
|
||
("tradein", AccessState.ACTIVE): True,
|
||
("tradein", AccessState.DISABLED): False,
|
||
("auth", AccessState.ACTIVE): "active",
|
||
("auth", AccessState.TRIAL_EXPIRED): "trial_expired",
|
||
("auth", AccessState.DISABLED): "disabled",
|
||
}
|
||
|
||
|
||
def column_value(state: AccessState) -> bool | str:
|
||
"""Значение состояния *state* в колонке реестра для ТЕКУЩЕГО режима."""
|
||
store = config.settings.identity_store
|
||
try:
|
||
return _COLUMN_VALUE[(store, state)]
|
||
except KeyError:
|
||
raise AssertionError(
|
||
f"состояние {state.value!r} не существует в схеме {store!r} — "
|
||
f"такой тест имеет смысл только при identity_store='auth'"
|
||
) from None
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class SqlNames:
|
||
"""Имена, по которым fake-DB узнаёт запрос в ТЕКУЩЕМ режиме."""
|
||
|
||
users: str
|
||
sessions: str
|
||
access_state_column: str
|
||
access_state_sql_type: str
|
||
|
||
|
||
def sql_names() -> SqlNames:
|
||
"""Имена таблиц/колонки из `identity_schema()` — источник тот же, что у кода."""
|
||
schema = identity_schema()
|
||
return SqlNames(
|
||
users=schema.users_table,
|
||
sessions=schema.sessions_table,
|
||
access_state_column=schema.access_state_column,
|
||
access_state_sql_type=schema.access_state_sql_type,
|
||
)
|
||
|
||
|
||
def assert_reads_access_state(sql: str, names: SqlNames) -> None:
|
||
"""Запрос, читающий состояние доступа, ОБЯЗАН брать колонку ТЕКУЩЕГО режима.
|
||
|
||
Ставится в те ветки fake-DB, которые отдают строку человека. Без неё fake
|
||
остаётся ЗЕЛЁНЫМ на захардкоженном `is_active AS access_state`: строку он
|
||
собирает из `_Store`, где ключ УЖЕ называется `access_state`, и про имя
|
||
колонки в SELECT'е ничего не знает — то есть запрос, невозможный на реальном
|
||
Postgres (`column "is_active" does not exist` в БД `auth`), проехал бы молча.
|
||
|
||
Измерено мутацией: захардкодить колонку в `team._employee_columns` — без
|
||
этой проверки все 48 тестов «Команды» остаются зелёными; с ней ветка
|
||
перестаёт матчиться, SQL доезжает до `raise AssertionError` в конце
|
||
`execute` и тесты краснеют.
|
||
|
||
Алиас проверяется отдельно от имени колонки: без `AS access_state`
|
||
вызывающий код читал бы то `is_active`, то `access_state`, то есть завёл бы
|
||
второе представление состояния — ровно то, чего эпик не допускает.
|
||
"""
|
||
expected = f"{names.access_state_column} AS access_state"
|
||
if expected not in sql:
|
||
raise AssertionError(
|
||
f"запрос к реестру не читает колонку состояния текущего режима ({expected!r}): {sql!r}"
|
||
)
|
||
|
||
|
||
def assert_insert_writes_access_state(sql: str, names: SqlNames) -> None:
|
||
"""INSERT в реестр обязан перечислять колонку состояния ТЕКУЩЕГО режима.
|
||
|
||
Проверяется именно СПИСОК КОЛОНОК, а не наличие подстроки: bind-параметр
|
||
называется `:access_state` в обоих режимах, поэтому `... , :access_state)`
|
||
в VALUES матчился бы всегда — и `INSERT INTO users (..., is_active)`
|
||
(невозможный в БД `auth`) проехал бы молча. Измерено мутацией.
|
||
"""
|
||
match = re.search(rf"INSERT INTO\s+{re.escape(names.users)}\s*\(([^)]*)\)", sql)
|
||
if match is None:
|
||
raise AssertionError(f"не разобрал список колонок INSERT'а в реестр: {sql!r}")
|
||
columns = {c.strip() for c in match.group(1).split(",")}
|
||
if names.access_state_column not in columns:
|
||
raise AssertionError(
|
||
f"INSERT в реестр не пишет колонку состояния текущего режима "
|
||
f"({names.access_state_column!r}); в списке: {sorted(columns)}"
|
||
)
|
||
|
||
|
||
def assert_update_writes_access_state(sql: str, names: SqlNames) -> None:
|
||
"""UPDATE реестра обязан присваивать колонку состояния ТЕКУЩЕГО режима — и
|
||
кастовать параметр в ЕЁ тип.
|
||
|
||
CAST здесь несущий: параметр может быть NULL («поле не пришло в PATCH» →
|
||
`COALESCE(CAST(:x AS T), col)`), и без явного типа Postgres тип NULL-параметра
|
||
не выведет. Захардкоженный `boolean` в текстовой схеме — ошибка уровня БД,
|
||
которую fake иначе не увидел бы.
|
||
"""
|
||
assignment = f"{names.access_state_column} = COALESCE("
|
||
if assignment not in sql:
|
||
raise AssertionError(
|
||
f"UPDATE реестра не присваивает колонку состояния текущего режима "
|
||
f"({assignment!r}): {sql!r}"
|
||
)
|
||
cast = f"CAST(:access_state AS {names.access_state_sql_type})"
|
||
if cast not in sql:
|
||
raise AssertionError(
|
||
f"UPDATE реестра кастует состояние не в тип текущей схемы ({cast!r}): {sql!r}"
|
||
)
|
||
|
||
|
||
def use_identity_mode(monkeypatch: pytest.MonkeyPatch, mode: str) -> str:
|
||
"""Переключает реестр на *mode* на время теста.
|
||
|
||
`reset_auth_db()` — на случай, если предыдущий тест успел построить engine
|
||
БД `auth`: закешированный engine пережил бы monkeypatch настроек (он живёт в
|
||
module-global, а не в `settings`) и утёк бы сюда.
|
||
"""
|
||
auth_db.reset_auth_db()
|
||
monkeypatch.setattr(config.settings, "identity_store", mode)
|
||
return mode
|
||
|
||
|
||
def patch_identity_sessions(monkeypatch: pytest.MonkeyPatch, make_db: Callable[[], Any]) -> None:
|
||
"""Подменяет ОБА источника сессии реестра так, чтобы работал РЕАЛЬНЫЙ
|
||
`identity_store.identity_session()` / `get_identity_db()`, а не их копия
|
||
в тесте.
|
||
|
||
Точки подмены выбраны настолько «низко», насколько возможно:
|
||
* `identity_store.SessionLocal` — то, что открывает `identity_session()`
|
||
в режиме "tradein" (импортирован по имени, поэтому патчим в
|
||
`identity_store`, а не в `app.core.db`);
|
||
* `auth_db.auth_session` — то, что открывают `identity_session()` и
|
||
`get_identity_db()` в режиме "auth" (`identity_store` держит ссылку на
|
||
МОДУЛЬ `auth_db`, поэтому подмена атрибута модуля видна ему сразу).
|
||
|
||
Благодаря этому ветвление по режиму остаётся на production-коде: тест не
|
||
повторяет его у себя, и регрессия в `get_identity_db` (например, если он
|
||
перестанет отдавать в режиме "tradein" тот же объект `Session`, что и
|
||
`get_db`) не сможет спрятаться за тестовым дублёром.
|
||
|
||
*make_db* вызывается БЕЗ аргументов и обязан отдавать новый fake-Session,
|
||
поддерживающий `with ... as db` (как настоящая `Session`).
|
||
"""
|
||
|
||
@contextmanager
|
||
def _fake_auth_session() -> Iterator[Any]:
|
||
with make_db() as db:
|
||
yield db
|
||
|
||
monkeypatch.setattr(identity_store, "SessionLocal", make_db)
|
||
monkeypatch.setattr(auth_db, "auth_session", _fake_auth_session)
|