gendesign/tradein-mvp/backend/tests/support/identity_modes.py
bot-backend cdf493f345
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
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
2026-08-21 12:01:52 +00:00

199 lines
12 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.

"""Помощники для тестов, зависящих от того, В КАКОМ РЕЕСТРЕ живут люди.
Эпик «единый вход»: `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)