test: фабрики справочников, фикстуры app/auth_client/seeded

Фабрики ГК/ЮЛ/ГСК/членства/пользователя (flush, без commit), фикстуры seeded/app/auth_client
в conftest (override сессии через begin_nested), test_me_and_dev_token переведён на общие
фикстуры (сочетания флагов остались на локальном _build_app), test_factories.py проверяет фабрики.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-04 21:10:41 +04:00
parent cd6cca32f2
commit 8eb8592452
6 changed files with 360 additions and 58 deletions

View File

@ -7,14 +7,23 @@
""" """
import uuid import uuid
from collections.abc import AsyncIterator from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AsyncExitStack
from pathlib import Path from pathlib import Path
import asyncpg import asyncpg
import httpx
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from fastapi import FastAPI
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine
from scripts.seed import run_seed
from src.api.router import build_api_router
from src.core.auth import build_verifier
from src.core.config import get_settings
from src.core.exception_handlers import register_exception_handlers
from src.db.db import get_async_session
from tests.fixtures_helpers import fixtures_skip_reason, resolve_fixtures_dir from tests.fixtures_helpers import fixtures_skip_reason, resolve_fixtures_dir
from tests.pg_helpers import ( # scratch_db_url — реэкспорт фикстуры, чтобы pytest её нашёл from tests.pg_helpers import ( # scratch_db_url — реэкспорт фикстуры, чтобы pytest её нашёл
alembic_async, alembic_async,
@ -90,3 +99,59 @@ async def db_session(db_engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
await session.close() await session.close()
await transaction.rollback() await transaction.rollback()
await connection.close() await connection.close()
AuthClientFactory = Callable[[str], Awaitable[httpx.AsyncClient]]
@pytest_asyncio.fixture
async def seeded(db_session: AsyncSession) -> AsyncSession:
"""`db_session` с засеянными справочниками и dev-пользователями (`run_seed(dev=True)`)."""
await run_seed(db_session, dev=True)
await db_session.flush()
return db_session
@pytest.fixture
def app(db_session: AsyncSession) -> FastAPI:
"""Приложение на `build_api_router` с dev-эмитентом (`DEBUG` и `AUTH_DEV_ISSUER`) поверх `db_session`.
Override сессии повторяет транзакцию запроса: `begin_nested()` — ошибка в запросе откатывает лишь до SAVEPOINT,
данные теста сохраняются; без lifespan, `storage` отсутствует.
"""
settings = get_settings().model_copy(update={"DEBUG": True, "AUTH_DEV_ISSUER": True})
application = FastAPI()
register_exception_handlers(application)
application.include_router(build_api_router(settings))
async def override_session() -> AsyncIterator[AsyncSession]:
async with db_session.begin_nested(): # как транзакция запроса: ошибка → откат до SAVEPOINT
yield db_session
application.dependency_overrides[get_async_session] = override_session
application.state.token_verifier = build_verifier(settings)
application.state.storage = None
return application
@pytest_asyncio.fixture
async def auth_client(app: FastAPI, seeded: AsyncSession) -> AsyncIterator[AuthClientFactory]: # noqa: ARG001
"""Фабрика клиентов: `await auth_client("ka_rf@armka.local")` — клиент с dev-токеном, `base_url=.../api/v1`.
Клиенты закрываются в teardown. Dev-пользователи seed: ka_rf, rka, client_manager, ka_go, curator_go, expert, admin
(`@armka.local`).
"""
async with AsyncExitStack() as stack:
async def factory(email: str) -> httpx.AsyncClient:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as anonymous:
response = await anonymous.post("/api/v1/dev/token", json={"email": email})
assert response.status_code == 200, response.text # noqa: PLR2004,S101
token = response.json()["access_token"]
client = httpx.AsyncClient(
transport=transport, base_url="http://test/api/v1", headers={"Authorization": f"Bearer {token}"}
)
return await stack.enter_async_context(client)
yield factory

View File

@ -1,5 +1,8 @@
"""Фабрики тестовых данных для БД-тестов. """Фабрики тестовых данных для БД-тестов.
- `make_regional_office`, `make_company_group`, `make_legal_entity`, `make_related_group`, `make_membership`,
`make_user` — справочники Среза 1 (`flush`, без `commit`): откатываются фикстурой `db_session`. Идентификаторы по
умолчанию случайные (ИНН, коды, имена филиалов), поэтому фабрики можно вызывать несколько раз в одном тесте.
- `make_osv_entry` — минимальная цепочка FK поверх сессии (`flush`, без `commit`): откатывается фикстурой `db_session`. - `make_osv_entry` — минимальная цепочка FK поверх сессии (`flush`, без `commit`): откатывается фикстурой `db_session`.
- `CommittedJobs` — задания распознавания, ЗАФИКСИРОВАННЫЕ в БД (`commit`): для фоновых процессов обмена, которые - `CommittedJobs` — задания распознавания, ЗАФИКСИРОВАННЫЕ в БД (`commit`): для фоновых процессов обмена, которые
работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. Всё созданное удаляется работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. Всё созданное удаляется
@ -8,20 +11,31 @@
import datetime import datetime
import decimal import decimal
import secrets
import uuid import uuid
from collections.abc import Sequence
from typing import Any from typing import Any
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.models.directories import LegalEntity from src.models.access import AppUser, Role, UserRole
from src.models.directories import (
CompanyGroup,
LegalEntity,
RegionalOffice,
RelatedGroup,
RelatedGroupMember,
)
from src.models.enums import ( from src.models.enums import (
DocumentStatus, DocumentStatus,
DocumentType, DocumentType,
EntityType,
FileStatus, FileStatus,
FileType, FileType,
RecognitionPipeline, RecognitionPipeline,
RecognitionStatus, RecognitionStatus,
RelatedGroupKind,
S3BucketRole, S3BucketRole,
) )
from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.files import Document, RecognitionJob, S3Object, StoredFile
@ -30,6 +44,121 @@ from src.models.osv import OsvEntry
DEFAULT_FILE_TYPES = {RecognitionPipeline.tabular: FileType.XLSX, RecognitionPipeline.image: FileType.PDF} DEFAULT_FILE_TYPES = {RecognitionPipeline.tabular: FileType.XLSX, RecognitionPipeline.image: FileType.PDF}
def _now() -> datetime.datetime:
return datetime.datetime.now(datetime.UTC)
async def make_regional_office(
session: AsyncSession, *, name: str | None = None, is_head_office: bool = False
) -> RegionalOffice:
"""Филиал; имя по умолчанию уникальное (`regional_office.name` UNIQUE)."""
office = RegionalOffice(name=name or f"Филиал {uuid.uuid4().hex[:8]}", is_head_office=is_head_office)
session.add(office)
await session.flush()
return office
async def make_company_group(
session: AsyncSession,
*,
name: str = "ГК Тест",
office: RegionalOffice | None = None,
crm_id: str | None = None,
code: str | None = None,
) -> CompanyGroup:
"""ГК; `code` по умолчанию `GK-T<6 hex>`, `valid_from` — сейчас."""
group = CompanyGroup(
code=code or f"GK-T{uuid.uuid4().hex[:6]}",
crm_id=crm_id,
name=name,
base_regional_office_id=office.id if office is not None else None,
valid_from=_now(),
)
session.add(group)
await session.flush()
return group
async def make_legal_entity( # noqa: PLR0913 - сигнатура фабрики фиксирована планом
session: AsyncSession,
*,
name: str = "ООО Тест",
inn: str | None = None,
entity_type: EntityType = EntityType.ЮЛ,
group: CompanyGroup | None = None,
office: RegionalOffice | None = None,
close_date: datetime.date | None = None,
) -> LegalEntity:
"""ЮЛ; ИНН по умолчанию — 10 случайных цифр, филиал по умолчанию — базовый филиал группы."""
office_id = office.id if office is not None else (group.base_regional_office_id if group is not None else None)
entity = LegalEntity(
inn=inn or "".join(secrets.choice("0123456789") for _ in range(10)),
entity_type=entity_type,
name=name,
company_group_id=group.id if group is not None else None,
regional_office_id=office_id,
close_date=close_date,
valid_from=_now(),
)
session.add(entity)
await session.flush()
return entity
async def make_related_group(
session: AsyncSession, *, group: CompanyGroup, kind: RelatedGroupKind = RelatedGroupKind.gsk, name: str = "ГСК Тест"
) -> RelatedGroup:
"""ГСК/ГСЗ группы `group`; код `GSK-T…`/`GSZ-T…`."""
related = RelatedGroup(
code=f"{kind.value.upper()}-T{uuid.uuid4().hex[:6]}",
company_group_id=group.id,
kind=kind,
name=name,
created_date=_now().date(),
)
session.add(related)
await session.flush()
return related
async def make_membership(
session: AsyncSession, *, related_group: RelatedGroup, entity: LegalEntity, is_current: bool = True
) -> RelatedGroupMember:
"""Членство ЮЛ в ГСК/ГСЗ; `kind` копируется из группы (v5.14)."""
now = _now()
member = RelatedGroupMember(
related_group_id=related_group.id,
legal_entity_id=entity.id,
kind=related_group.kind,
valid_from=now,
valid_to=None if is_current else now,
is_current=is_current,
)
session.add(member)
await session.flush()
return member
async def make_user( # noqa: PLR0913 - сигнатура фабрики фиксирована планом
session: AsyncSession,
*,
email: str,
office: RegionalOffice,
roles: Sequence[str] = (),
full_name: str = "Тест Тестов",
is_active: bool = True,
) -> AppUser:
"""Пользователь с ролями по `Role.code` (роли должны быть засеяны: фикстура `seeded`)."""
user = AppUser(email=email, full_name=full_name, regional_office_id=office.id, is_active=is_active)
session.add(user)
await session.flush()
for code in roles:
role_id = (await session.execute(select(Role.id).where(Role.code == code))).scalar_one()
session.add(UserRole(user_id=user.id, role_id=role_id))
await session.flush()
return user
async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry: async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry:
"""Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`. """Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`.

112
api/tests/test_factories.py Normal file
View File

@ -0,0 +1,112 @@
"""Фабрики справочников и фикстуры `app` / `auth_client` / `seeded` (задача 1.3): проверка до API-тестов."""
import pytest
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.access import UserRole
from src.models.enums import EntityType, RelatedGroupKind
from tests.conftest import AuthClientFactory
from tests.factories import (
make_company_group,
make_legal_entity,
make_membership,
make_regional_office,
make_related_group,
make_user,
)
HTTP_OK = 200
INN_LENGTH = 10
@pytest.mark.integration
async def test_directory_factories_build_consistent_chain(db_session: AsyncSession) -> None:
office = await make_regional_office(db_session)
head = await make_regional_office(db_session, name="ГО Тест", is_head_office=True)
group = await make_company_group(db_session, office=office, crm_id="CRM-1")
entity = await make_legal_entity(db_session, group=group)
other = await make_legal_entity(db_session, name="ИП Тест", entity_type=EntityType.ИП, office=head)
gsk = await make_related_group(db_session, group=group)
gsz = await make_related_group(db_session, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ Тест")
member = await make_membership(db_session, related_group=gsk, entity=entity)
# v5.14: одно ЮЛ одновременно в ГСК и ГСЗ
member_gsz = await make_membership(db_session, related_group=gsz, entity=entity)
assert head.is_head_office is True
assert office.is_head_office is False
assert group.code.startswith("GK-T")
assert group.base_regional_office_id == office.id
assert group.valid_from is not None
assert entity.regional_office_id == office.id # по умолчанию — филиал группы
assert entity.entity_type == EntityType.ЮЛ
assert len(entity.inn) == INN_LENGTH
assert entity.inn != other.inn
assert other.regional_office_id == head.id
assert other.company_group_id is None
assert gsk.code.startswith("GSK-T")
assert gsz.code.startswith("GSZ-T")
assert member.kind == RelatedGroupKind.gsk
assert member.is_current is True
assert member.valid_to is None
assert member_gsz.kind == RelatedGroupKind.gsz
@pytest.mark.integration
async def test_membership_not_current_is_closed(db_session: AsyncSession) -> None:
group = await make_company_group(db_session)
entity = await make_legal_entity(db_session, group=group)
related = await make_related_group(db_session, group=group)
closed = await make_membership(db_session, related_group=related, entity=entity, is_current=False)
again = await make_membership(db_session, related_group=related, entity=entity) # закрытая версия не мешает
assert closed.is_current is False
assert closed.valid_to is not None
assert again.is_current is True
@pytest.mark.integration
async def test_second_current_membership_in_same_kind_is_rejected(db_session: AsyncSession) -> None:
"""Фабрики не обходят ограничения БД: ЮЛ в двух ГСК одновременно — нарушение partial UNIQUE (v5.14)."""
group = await make_company_group(db_session)
entity = await make_legal_entity(db_session, group=group)
first = await make_related_group(db_session, group=group)
second = await make_related_group(db_session, group=group, name="ГСК-2")
await make_membership(db_session, related_group=first, entity=entity)
with pytest.raises(IntegrityError):
async with db_session.begin_nested():
await make_membership(db_session, related_group=second, entity=entity)
@pytest.mark.integration
async def test_make_user_assigns_seeded_roles(seeded: AsyncSession) -> None:
office = await make_regional_office(seeded)
user = await make_user(seeded, email="factory@armka.local", office=office, roles=["KA_RF", "EXPERT"])
plain = await make_user(seeded, email="plain@armka.local", office=office, is_active=False)
count = len((await seeded.execute(select(UserRole).where(UserRole.user_id == user.id))).scalars().all())
assert count == 2 # noqa: PLR2004
assert user.regional_office_id == office.id
assert plain.is_active is False
assert not (await seeded.execute(select(UserRole).where(UserRole.user_id == plain.id))).scalars().all()
@pytest.mark.integration
async def test_auth_client_gets_me_for_dev_user(auth_client: AuthClientFactory) -> None:
client = await auth_client("ka_rf@armka.local")
response = await client.get("/me")
assert response.status_code == HTTP_OK, response.text
assert response.json()["email"] == "ka_rf@armka.local"
assert response.json()["roles"] == ["KA_RF"]
@pytest.mark.integration
async def test_auth_client_sees_data_created_by_factories(
db_session: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Пользователь, созданный фабрикой в той же сессии, получает токен и проходит `/me` (одна `db_session`)."""
office = await make_regional_office(db_session)
await make_user(db_session, email="made@armka.local", office=office, roles=["KA_RF"])
client = await auth_client("made@armka.local")
response = await client.get("/me")
assert response.status_code == HTTP_OK, response.text
assert response.json()["regional_office"]["id"] == str(office.id)

View File

@ -1,7 +1,8 @@
"""`GET /api/v1/me` и `POST /api/v1/dev/token` (spec authn-authz, задача 18). """`GET /api/v1/me` и `POST /api/v1/dev/token` (spec authn-authz, задача 18).
Мини-приложения собираются в тесте на `build_api_router(settings)` под разные сочетания `DEBUG` / `AUTH_DEV_ISSUER` Основное приложение — общие фикстуры `app` / `auth_client` / `seeded` (`tests/conftest.py`, dev-эмитент включён).
(без lifespan, сессия — `db_session` с откатом); отдельный тест проходит через полный `src.app.app`. Мини-приложения под другие сочетания `DEBUG` / `AUTH_DEV_ISSUER` собирает локальный `_build_app` (без lifespan,
сессия — `db_session` с откатом); отдельный тест проходит через полный `src.app.app`.
""" """
import importlib import importlib
@ -16,7 +17,6 @@ from fastapi import FastAPI
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from scripts.seed import run_seed
from src.api.dependencies.auth import current_user from src.api.dependencies.auth import current_user
from src.api.router import build_api_router, router from src.api.router import build_api_router, router
from src.core.auth import DevHs256Verifier, build_verifier from src.core.auth import DevHs256Verifier, build_verifier
@ -25,6 +25,7 @@ from src.core.exception_handlers import register_exception_handlers
from src.db.db import get_async_session from src.db.db import get_async_session
from src.models.access import AppUser from src.models.access import AppUser
from src.schemas.me import MeResponse from src.schemas.me import MeResponse
from tests.conftest import AuthClientFactory
HTTP_OK = 200 HTTP_OK = 200
HTTP_UNAUTHORIZED = 401 HTTP_UNAUTHORIZED = 401
@ -87,18 +88,6 @@ def _build_app(session: AsyncSession, settings: Settings) -> FastAPI:
return app return app
@pytest.fixture
async def seeded(db_session: AsyncSession) -> AsyncSession:
await run_seed(db_session, dev=True)
await db_session.flush()
return db_session
@pytest.fixture
async def dev_app(seeded: AsyncSession) -> FastAPI:
return _build_app(seeded, _settings(debug=True, dev_issuer=True))
async def _dev_token(client: httpx.AsyncClient, email: str) -> str: async def _dev_token(client: httpx.AsyncClient, email: str) -> str:
response = await client.post("/api/v1/dev/token", json={"email": email}) response = await client.post("/api/v1/dev/token", json={"email": email})
assert response.status_code == HTTP_OK, response.text assert response.status_code == HTTP_OK, response.text
@ -109,9 +98,9 @@ async def _dev_token(client: httpx.AsyncClient, email: str) -> str:
@pytest.mark.integration @pytest.mark.integration
async def test_me_for_ka_rf_matches_matrix(dev_app: FastAPI, seeded: AsyncSession) -> None: async def test_me_for_ka_rf_matches_matrix(app: FastAPI, seeded: AsyncSession) -> None:
"""Сценарий «Ответ для dev-пользователя»: roles = ["KA_RF"], permissions = права KA_RF по §10, отсортированы.""" """Сценарий «Ответ для dev-пользователя»: roles = ["KA_RF"], permissions = права KA_RF по §10, отсортированы."""
async with _client(dev_app) as client: async with _client(app) as client:
token = await _dev_token(client, "ka_rf@armka.local") token = await _dev_token(client, "ka_rf@armka.local")
response = await client.get("/api/v1/me", headers=_bearer(token)) response = await client.get("/api/v1/me", headers=_bearer(token))
assert response.status_code == HTTP_OK, response.text assert response.status_code == HTTP_OK, response.text
@ -135,10 +124,9 @@ async def test_me_for_ka_rf_matches_matrix(dev_app: FastAPI, seeded: AsyncSessio
@pytest.mark.integration @pytest.mark.integration
async def test_me_for_admin(dev_app: FastAPI) -> None: async def test_me_for_admin(auth_client: AuthClientFactory) -> None:
async with _client(dev_app) as client: client = await auth_client("admin@armka.local")
token = await _dev_token(client, "admin@armka.local") response = await client.get("/me")
response = await client.get("/api/v1/me", headers=_bearer(token))
assert response.status_code == HTTP_OK assert response.status_code == HTTP_OK
body = response.json() body = response.json()
assert body["roles"] == ["ADMIN"] assert body["roles"] == ["ADMIN"]
@ -146,50 +134,52 @@ async def test_me_for_admin(dev_app: FastAPI) -> None:
@pytest.mark.integration @pytest.mark.integration
async def test_me_head_office_flag(dev_app: FastAPI) -> None: async def test_me_head_office_flag(auth_client: AuthClientFactory) -> None:
"""У пользователя головного офиса `is_head_office` = true, у филиала — false (по данным seed).""" """У пользователя головного офиса `is_head_office` = true, у филиала — false (по данным seed)."""
async with _client(dev_app) as client: flags = {}
flags = {} for email in ("ka_go@armka.local", "ka_rf@armka.local"):
for email in ("ka_go@armka.local", "ka_rf@armka.local"): client = await auth_client(email)
token = await _dev_token(client, email) body = (await client.get("/me")).json()
body = (await client.get("/api/v1/me", headers=_bearer(token))).json() flags[email] = body["regional_office"]["is_head_office"]
flags[email] = body["regional_office"]["is_head_office"]
assert flags["ka_go@armka.local"] is True assert flags["ka_go@armka.local"] is True
assert flags["ka_rf@armka.local"] is False assert flags["ka_rf@armka.local"] is False
@pytest.mark.integration @pytest.mark.integration
async def test_me_accepts_token_in_any_email_case(dev_app: FastAPI) -> None: @pytest.mark.usefixtures("seeded")
async def test_me_accepts_token_in_any_email_case(app: FastAPI) -> None:
verifier = DevHs256Verifier(get_settings().AUTH_DEV_SECRET) verifier = DevHs256Verifier(get_settings().AUTH_DEV_SECRET)
async with _client(dev_app) as client: async with _client(app) as client:
response = await client.get("/api/v1/me", headers=_bearer(verifier.issue_token("KA_RF@Armka.Local"))) response = await client.get("/api/v1/me", headers=_bearer(verifier.issue_token("KA_RF@Armka.Local")))
assert response.status_code == HTTP_OK assert response.status_code == HTTP_OK
assert response.json()["email"] == "ka_rf@armka.local" assert response.json()["email"] == "ka_rf@armka.local"
@pytest.mark.integration @pytest.mark.integration
async def test_me_without_token_is_401(dev_app: FastAPI) -> None: @pytest.mark.usefixtures("seeded")
async with _client(dev_app) as client: async def test_me_without_token_is_401(app: FastAPI) -> None:
async with _client(app) as client:
response = await client.get("/api/v1/me") response = await client.get("/api/v1/me")
assert response.status_code == HTTP_UNAUTHORIZED assert response.status_code == HTTP_UNAUTHORIZED
assert response.headers["www-authenticate"] == "Bearer" assert response.headers["www-authenticate"] == "Bearer"
@pytest.mark.integration @pytest.mark.integration
async def test_me_unknown_user_is_403(dev_app: FastAPI) -> None: @pytest.mark.usefixtures("seeded")
async def test_me_unknown_user_is_403(app: FastAPI) -> None:
verifier = DevHs256Verifier(get_settings().AUTH_DEV_SECRET) verifier = DevHs256Verifier(get_settings().AUTH_DEV_SECRET)
async with _client(dev_app) as client: async with _client(app) as client:
response = await client.get("/api/v1/me", headers=_bearer(verifier.issue_token("nobody@armka.local"))) response = await client.get("/api/v1/me", headers=_bearer(verifier.issue_token("nobody@armka.local")))
assert response.status_code == HTTP_FORBIDDEN assert response.status_code == HTTP_FORBIDDEN
@pytest.mark.integration @pytest.mark.integration
async def test_me_user_without_roles_has_empty_lists(dev_app: FastAPI, seeded: AsyncSession) -> None: async def test_me_user_without_roles_has_empty_lists(app: FastAPI, seeded: AsyncSession) -> None:
office_id = (await seeded.execute(select(AppUser.regional_office_id).limit(1))).scalar_one() office_id = (await seeded.execute(select(AppUser.regional_office_id).limit(1))).scalar_one()
seeded.add(AppUser(email="norole@armka.local", full_name="Без Ролей", regional_office_id=office_id)) seeded.add(AppUser(email="norole@armka.local", full_name="Без Ролей", regional_office_id=office_id))
await seeded.flush() await seeded.flush()
verifier = DevHs256Verifier(get_settings().AUTH_DEV_SECRET) verifier = DevHs256Verifier(get_settings().AUTH_DEV_SECRET)
async with _client(dev_app) as client: async with _client(app) as client:
response = await client.get("/api/v1/me", headers=_bearer(verifier.issue_token("norole@armka.local"))) response = await client.get("/api/v1/me", headers=_bearer(verifier.issue_token("norole@armka.local")))
assert response.status_code == HTTP_OK assert response.status_code == HTTP_OK
assert response.json()["roles"] == [] assert response.json()["roles"] == []
@ -208,8 +198,9 @@ def test_me_route_has_no_permission_requirement() -> None:
@pytest.mark.integration @pytest.mark.integration
async def test_dev_token_for_admin(dev_app: FastAPI) -> None: @pytest.mark.usefixtures("seeded")
async with _client(dev_app) as client: async def test_dev_token_for_admin(app: FastAPI) -> None:
async with _client(app) as client:
response = await client.post("/api/v1/dev/token", json={"email": "admin@armka.local"}) response = await client.post("/api/v1/dev/token", json={"email": "admin@armka.local"})
assert response.status_code == HTTP_OK assert response.status_code == HTTP_OK
body = response.json() body = response.json()
@ -221,8 +212,9 @@ async def test_dev_token_for_admin(dev_app: FastAPI) -> None:
@pytest.mark.integration @pytest.mark.integration
async def test_dev_token_is_hs256_for_8_hours(dev_app: FastAPI) -> None: @pytest.mark.usefixtures("seeded")
async with _client(dev_app) as client: async def test_dev_token_is_hs256_for_8_hours(app: FastAPI) -> None:
async with _client(app) as client:
token = await _dev_token(client, "ka_rf@armka.local") token = await _dev_token(client, "ka_rf@armka.local")
header = jwt.get_unverified_header(token) header = jwt.get_unverified_header(token)
payload = jwt.decode(token, options={"verify_signature": False}) payload = jwt.decode(token, options={"verify_signature": False})
@ -232,39 +224,41 @@ async def test_dev_token_is_hs256_for_8_hours(dev_app: FastAPI) -> None:
@pytest.mark.integration @pytest.mark.integration
async def test_dev_token_email_case_insensitive(dev_app: FastAPI) -> None: @pytest.mark.usefixtures("seeded")
async with _client(dev_app) as client: async def test_dev_token_email_case_insensitive(app: FastAPI) -> None:
async with _client(app) as client:
token = await _dev_token(client, " KA_RF@Armka.Local ") token = await _dev_token(client, " KA_RF@Armka.Local ")
me = await client.get("/api/v1/me", headers=_bearer(token)) me = await client.get("/api/v1/me", headers=_bearer(token))
assert me.status_code == HTTP_OK assert me.status_code == HTTP_OK
@pytest.mark.integration @pytest.mark.integration
async def test_dev_token_unknown_user_is_404(dev_app: FastAPI) -> None: @pytest.mark.usefixtures("seeded")
async with _client(dev_app) as client: async def test_dev_token_unknown_user_is_404(app: FastAPI) -> None:
async with _client(app) as client:
response = await client.post("/api/v1/dev/token", json={"email": "nobody@armka.local"}) response = await client.post("/api/v1/dev/token", json={"email": "nobody@armka.local"})
assert response.status_code == HTTP_NOT_FOUND assert response.status_code == HTTP_NOT_FOUND
assert response.json() == USER_NOT_FOUND assert response.json() == USER_NOT_FOUND
@pytest.mark.integration @pytest.mark.integration
async def test_dev_token_inactive_user_is_404(dev_app: FastAPI, seeded: AsyncSession) -> None: async def test_dev_token_inactive_user_is_404(app: FastAPI, seeded: AsyncSession) -> None:
user = (await seeded.execute(select(AppUser).where(AppUser.email == "expert@armka.local"))).scalar_one() user = (await seeded.execute(select(AppUser).where(AppUser.email == "expert@armka.local"))).scalar_one()
user.is_active = False user.is_active = False
await seeded.flush() await seeded.flush()
async with _client(dev_app) as client: async with _client(app) as client:
response = await client.post("/api/v1/dev/token", json={"email": "expert@armka.local"}) response = await client.post("/api/v1/dev/token", json={"email": "expert@armka.local"})
assert response.status_code == HTTP_NOT_FOUND assert response.status_code == HTTP_NOT_FOUND
assert response.json() == USER_NOT_FOUND assert response.json() == USER_NOT_FOUND
@pytest.mark.integration @pytest.mark.integration
async def test_dev_token_ambiguous_email_is_error(dev_app: FastAPI, seeded: AsyncSession) -> None: async def test_dev_token_ambiguous_email_is_error(app: FastAPI, seeded: AsyncSession) -> None:
"""Два пользователя с email, различающимся регистром, — отказ (fail-closed), токен не выпускается.""" """Два пользователя с email, различающимся регистром, — отказ (fail-closed), токен не выпускается."""
office_id = (await seeded.execute(select(AppUser.regional_office_id).limit(1))).scalar_one() office_id = (await seeded.execute(select(AppUser.regional_office_id).limit(1))).scalar_one()
seeded.add(AppUser(email="KA_RF@armka.local", full_name="Двойник", regional_office_id=office_id)) seeded.add(AppUser(email="KA_RF@armka.local", full_name="Двойник", regional_office_id=office_id))
await seeded.flush() await seeded.flush()
async with _client(dev_app) as client: async with _client(app) as client:
response = await client.post("/api/v1/dev/token", json={"email": "ka_rf@armka.local"}) response = await client.post("/api/v1/dev/token", json={"email": "ka_rf@armka.local"})
assert response.status_code == 409 # noqa: PLR2004 assert response.status_code == 409 # noqa: PLR2004
assert response.json() == {"detail": "неоднозначная учётная запись"} assert response.json() == {"detail": "неоднозначная учётная запись"}
@ -274,15 +268,17 @@ async def test_dev_token_ambiguous_email_is_error(dev_app: FastAPI, seeded: Asyn
@pytest.mark.parametrize( @pytest.mark.parametrize(
"body", [{}, {"email": ""}, {"email": " "}, {"email": 5}, {"email": None}, {"mail": "a@b.c"}] "body", [{}, {"email": ""}, {"email": " "}, {"email": 5}, {"email": None}, {"mail": "a@b.c"}]
) )
async def test_dev_token_invalid_body_is_422(dev_app: FastAPI, body: dict[str, Any]) -> None: @pytest.mark.usefixtures("seeded")
async with _client(dev_app) as client: async def test_dev_token_invalid_body_is_422(app: FastAPI, body: dict[str, Any]) -> None:
async with _client(app) as client:
response = await client.post("/api/v1/dev/token", json=body) response = await client.post("/api/v1/dev/token", json=body)
assert response.status_code == HTTP_UNPROCESSABLE assert response.status_code == HTTP_UNPROCESSABLE
@pytest.mark.integration @pytest.mark.integration
async def test_dev_token_not_json_is_422(dev_app: FastAPI) -> None: @pytest.mark.usefixtures("seeded")
async with _client(dev_app) as client: async def test_dev_token_not_json_is_422(app: FastAPI) -> None:
async with _client(app) as client:
response = await client.post( response = await client.post(
"/api/v1/dev/token", content=b"not-json", headers={"content-type": "application/json"} "/api/v1/dev/token", content=b"not-json", headers={"content-type": "application/json"}
) )

View File

@ -243,9 +243,9 @@ async def get_async_session() -> AsyncIterator[AsyncSession]:
``` ```
- `auth_client(app, seeded) -> AuthClientFactory` где `AuthClientFactory = Callable[[str], Awaitable[httpx.AsyncClient]]`: по email запрашивает `POST /api/v1/dev/token`, возвращает клиент с `headers={"Authorization": f"Bearer {token}"}` и `base_url="http://test/api/v1"`; клиенты закрываются в teardown (`AsyncExitStack`). Dev-пользователи seed: `ka_rf@armka.local` (Ростовский РФ), `rka@armka.local` (Ростовский РФ), `client_manager@armka.local` (Краснодарский РФ), `ka_go@armka.local`, `curator_go@armka.local`, `expert@armka.local`, `admin@armka.local` (ГО). - `auth_client(app, seeded) -> AuthClientFactory` где `AuthClientFactory = Callable[[str], Awaitable[httpx.AsyncClient]]`: по email запрашивает `POST /api/v1/dev/token`, возвращает клиент с `headers={"Authorization": f"Bearer {token}"}` и `base_url="http://test/api/v1"`; клиенты закрываются в teardown (`AsyncExitStack`). Dev-пользователи seed: `ka_rf@armka.local` (Ростовский РФ), `rka@armka.local` (Ростовский РФ), `client_manager@armka.local` (Краснодарский РФ), `ka_go@armka.local`, `curator_go@armka.local`, `expert@armka.local`, `admin@armka.local` (ГО).
- [ ] **Step 1:** написать фабрики и фикстуры; `test_me_and_dev_token.py` перевести на `app`/`auth_client` там, где он строит приложение сам (тесты сочетаний флагов `DEBUG`/`AUTH_DEV_ISSUER` оставить на локальном `_build_app`). - [x] **Step 1:** написать фабрики и фикстуры; `test_me_and_dev_token.py` перевести на `app`/`auth_client` там, где он строит приложение сам (тесты сочетаний флагов `DEBUG`/`AUTH_DEV_ISSUER` оставить на локальном `_build_app`).
- [ ] **Step 2:** `pytest -m integration tests/test_me_and_dev_token.py -v` → PASS; `ruff`, `mypy` чисты. - [x] **Step 2:** `pytest -m integration tests/test_me_and_dev_token.py -v` → PASS; `ruff`, `mypy` чисты.
- [ ] **Step 3: Commit** `test: фабрики справочников, фикстуры app/auth_client/seeded`. - [x] **Step 3: Commit** `test: фабрики справочников, фикстуры app/auth_client/seeded`.
--- ---

View File

@ -19,7 +19,7 @@ test-integration`). Пути — от корня репозитория. Доп
в `core/exception_handlers.py` (формат `{"detail": {"code", "message", …}}`); `schemas/common.py`: `Page[T]` в `core/exception_handlers.py` (формат `{"detail": {"code", "message", …}}`); `schemas/common.py`: `Page[T]`
`{items,total,limit,offset}`, параметры `limit ≤ 200`. Проверка: unit-тесты обработчиков; интеграционный тест через `{items,total,limit,offset}`, параметры `limit ≤ 200`. Проверка: unit-тесты обработчиков; интеграционный тест через
`scratch_db_url`: эндпоинт-заглушка с `get_uow_session` коммитит при 2xx и откатывает при исключении. `scratch_db_url`: эндпоинт-заглушка с `get_uow_session` коммитит при 2xx и откатывает при исключении.
- [ ] 1.3 **Фабрики и клиент тестов.** `tests/factories.py`: `make_regional_office`, `make_company_group`, `make_legal_entity`, - [x] 1.3 **Фабрики и клиент тестов.** `tests/factories.py`: `make_regional_office`, `make_company_group`, `make_legal_entity`,
`make_related_group`, `make_membership`, `make_user(email, office, roles)`; `tests/conftest.py`: `seeded`, `app`, `make_related_group`, `make_membership`, `make_user(email, office, roles)`; `tests/conftest.py`: `seeded`, `app`,
`auth_client(email) -> httpx.AsyncClient` с dev-токеном. Переписать `test_me_and_dev_token.py` на новые фикстуры. `auth_client(email) -> httpx.AsyncClient` с dev-токеном. Переписать `test_me_and_dev_token.py` на новые фикстуры.
Проверка: существующие тесты зелёные. Проверка: существующие тесты зелёные.