diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 3f5c8df..f892361 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -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 diff --git a/api/tests/factories.py b/api/tests/factories.py index 949edbf..c98b579 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -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`. diff --git a/api/tests/test_factories.py b/api/tests/test_factories.py new file mode 100644 index 0000000..c04d5f5 --- /dev/null +++ b/api/tests/test_factories.py @@ -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) diff --git a/api/tests/test_me_and_dev_token.py b/api/tests/test_me_and_dev_token.py index b773e8e..22e051b 100644 --- a/api/tests/test_me_and_dev_token.py +++ b/api/tests/test_me_and_dev_token.py @@ -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"} ) diff --git a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md index 4ec6936..72cd514 100644 --- a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md +++ b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md @@ -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`. --- diff --git a/openspec/changes/slice-1-directories/tasks.md b/openspec/changes/slice-1-directories/tasks.md index e559724..c7f3974 100644 --- a/openspec/changes/slice-1-directories/tasks.md +++ b/openspec/changes/slice-1-directories/tasks.md @@ -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` на новые фикстуры. Проверка: существующие тесты зелёные.