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
from collections.abc import AsyncIterator
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AsyncExitStack
from pathlib import Path
import asyncpg
import httpx
import pytest
import pytest_asyncio
from fastapi import FastAPI
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.pg_helpers import ( # scratch_db_url — реэкспорт фикстуры, чтобы pytest её нашёл
alembic_async,
@ -90,3 +99,59 @@ async def db_session(db_engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
await session.close()
await transaction.rollback()
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`.
- `CommittedJobs` — задания распознавания, ЗАФИКСИРОВАННЫЕ в БД (`commit`): для фоновых процессов обмена, которые
работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. Всё созданное удаляется
@ -8,20 +11,31 @@
import datetime
import decimal
import secrets
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import delete, func, select
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 (
DocumentStatus,
DocumentType,
EntityType,
FileStatus,
FileType,
RecognitionPipeline,
RecognitionStatus,
RelatedGroupKind,
S3BucketRole,
)
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}
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:
"""Строка ОСВ с минимальной цепочкой 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).
Мини-приложения собираются в тесте на `build_api_router(settings)` под разные сочетания `DEBUG` / `AUTH_DEV_ISSUER`
(без lifespan, сессия — `db_session` с откатом); отдельный тест проходит через полный `src.app.app`.
Основное приложение — общие фикстуры `app` / `auth_client` / `seeded` (`tests/conftest.py`, dev-эмитент включён).
Мини-приложения под другие сочетания `DEBUG` / `AUTH_DEV_ISSUER` собирает локальный `_build_app` (без lifespan,
сессия — `db_session` с откатом); отдельный тест проходит через полный `src.app.app`.
"""
import importlib
@ -16,7 +17,6 @@ from fastapi import FastAPI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from scripts.seed import run_seed
from src.api.dependencies.auth import current_user
from src.api.router import build_api_router, router
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.models.access import AppUser
from src.schemas.me import MeResponse
from tests.conftest import AuthClientFactory
HTTP_OK = 200
HTTP_UNAUTHORIZED = 401
@ -87,18 +88,6 @@ def _build_app(session: AsyncSession, settings: Settings) -> FastAPI:
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:
response = await client.post("/api/v1/dev/token", json={"email": email})
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
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, отсортированы."""
async with _client(dev_app) as client:
async with _client(app) as client:
token = await _dev_token(client, "ka_rf@armka.local")
response = await client.get("/api/v1/me", headers=_bearer(token))
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
async def test_me_for_admin(dev_app: FastAPI) -> None:
async with _client(dev_app) as client:
token = await _dev_token(client, "admin@armka.local")
response = await client.get("/api/v1/me", headers=_bearer(token))
async def test_me_for_admin(auth_client: AuthClientFactory) -> None:
client = await auth_client("admin@armka.local")
response = await client.get("/me")
assert response.status_code == HTTP_OK
body = response.json()
assert body["roles"] == ["ADMIN"]
@ -146,50 +134,52 @@ async def test_me_for_admin(dev_app: FastAPI) -> None:
@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)."""
async with _client(dev_app) as client:
flags = {}
for email in ("ka_go@armka.local", "ka_rf@armka.local"):
token = await _dev_token(client, email)
body = (await client.get("/api/v1/me", headers=_bearer(token))).json()
flags[email] = body["regional_office"]["is_head_office"]
flags = {}
for email in ("ka_go@armka.local", "ka_rf@armka.local"):
client = await auth_client(email)
body = (await client.get("/me")).json()
flags[email] = body["regional_office"]["is_head_office"]
assert flags["ka_go@armka.local"] is True
assert flags["ka_rf@armka.local"] is False
@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)
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")))
assert response.status_code == HTTP_OK
assert response.json()["email"] == "ka_rf@armka.local"
@pytest.mark.integration
async def test_me_without_token_is_401(dev_app: FastAPI) -> None:
async with _client(dev_app) as client:
@pytest.mark.usefixtures("seeded")
async def test_me_without_token_is_401(app: FastAPI) -> None:
async with _client(app) as client:
response = await client.get("/api/v1/me")
assert response.status_code == HTTP_UNAUTHORIZED
assert response.headers["www-authenticate"] == "Bearer"
@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)
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")))
assert response.status_code == HTTP_FORBIDDEN
@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()
seeded.add(AppUser(email="norole@armka.local", full_name="Без Ролей", regional_office_id=office_id))
await seeded.flush()
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")))
assert response.status_code == HTTP_OK
assert response.json()["roles"] == []
@ -208,8 +198,9 @@ def test_me_route_has_no_permission_requirement() -> None:
@pytest.mark.integration
async def test_dev_token_for_admin(dev_app: FastAPI) -> None:
async with _client(dev_app) as client:
@pytest.mark.usefixtures("seeded")
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"})
assert response.status_code == HTTP_OK
body = response.json()
@ -221,8 +212,9 @@ async def test_dev_token_for_admin(dev_app: FastAPI) -> None:
@pytest.mark.integration
async def test_dev_token_is_hs256_for_8_hours(dev_app: FastAPI) -> None:
async with _client(dev_app) as client:
@pytest.mark.usefixtures("seeded")
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")
header = jwt.get_unverified_header(token)
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
async def test_dev_token_email_case_insensitive(dev_app: FastAPI) -> None:
async with _client(dev_app) as client:
@pytest.mark.usefixtures("seeded")
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 ")
me = await client.get("/api/v1/me", headers=_bearer(token))
assert me.status_code == HTTP_OK
@pytest.mark.integration
async def test_dev_token_unknown_user_is_404(dev_app: FastAPI) -> None:
async with _client(dev_app) as client:
@pytest.mark.usefixtures("seeded")
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"})
assert response.status_code == HTTP_NOT_FOUND
assert response.json() == USER_NOT_FOUND
@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.is_active = False
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"})
assert response.status_code == HTTP_NOT_FOUND
assert response.json() == USER_NOT_FOUND
@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), токен не выпускается."""
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))
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"})
assert response.status_code == 409 # noqa: PLR2004
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(
"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:
async with _client(dev_app) as client:
@pytest.mark.usefixtures("seeded")
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)
assert response.status_code == HTTP_UNPROCESSABLE
@pytest.mark.integration
async def test_dev_token_not_json_is_422(dev_app: FastAPI) -> None:
async with _client(dev_app) as client:
@pytest.mark.usefixtures("seeded")
async def test_dev_token_not_json_is_422(app: FastAPI) -> None:
async with _client(app) as client:
response = await client.post(
"/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` (ГО).
- [ ] **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` чисты.
- [ ] **Step 3: Commit** `test: фабрики справочников, фикстуры app/auth_client/seeded`.
- [x] **Step 1:** написать фабрики и фикстуры; `test_me_and_dev_token.py` перевести на `app`/`auth_client` там, где он строит приложение сам (тесты сочетаний флагов `DEBUG`/`AUTH_DEV_ISSUER` оставить на локальном `_build_app`).
- [x] **Step 2:** `pytest -m integration tests/test_me_and_dev_token.py -v` → PASS; `ruff`, `mypy` чисты.
- [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]`
`{items,total,limit,offset}`, параметры `limit ≤ 200`. Проверка: unit-тесты обработчиков; интеграционный тест через
`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`,
`auth_client(email) -> httpx.AsyncClient` с dev-токеном. Переписать `test_me_and_dev_token.py` на новые фикстуры.
Проверка: существующие тесты зелёные.