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:
parent
cd6cca32f2
commit
8eb8592452
@ -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
|
||||
|
||||
@ -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
112
api/tests/test_factories.py
Normal 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)
|
||||
@ -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"}
|
||||
)
|
||||
|
||||
@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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` на новые фикстуры.
|
||||
Проверка: существующие тесты зелёные.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user