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