arm-core/api/tests/test_api_consolidation_status.py
Andrey Epifancev e317fe59dd fix(consolidation): ревью Task 4 — общий lock_out, точнее тесты, решения #17–#24 в design.md
- I-1 (T4.1): lock_out — публичный в services/consolidations.py, копия в consolidation_locks удалена
- M-b (T4.3): удалён осиротевший комментарий в tests/factories.py
- M-c (T4.4): точная проверка ip_address/user_agent в аудите смены статуса
- M-d (T4.5): docstring теста Review Focus 4 ссылается на различающий тест порядка D-9
- design.md: решения без обсуждения #17 (R-8), #18 (R-9), #19–#24 (решения Task 4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 04:26:31 +04:00

451 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Ручные статусы версии консолидации Среза 4 и общий переход `transition` (spec consolidations-api «Статусы Среза 4»;
directory-policy «Видимость консолидаций и редактируемость версии»; audit-trail `CONSOLIDATION_STATUS_CHANGE`;
design D-6, D-8)."""
import datetime
import uuid
from typing import Any
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
from src.models.consolidation import (
ConsolidationDocument,
ConsolidationLock,
ConsolidationStatusHistory,
ConsolidationVersion,
)
from src.models.directories import LegalEntity, RegionalOffice
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter
from src.services import consolidation_locks, consolidation_status
from src.services.audit import AuditService
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_confirmed_osv, make_consolidation, make_legal_entity, make_lock
HTTP_OK = 200
HTTP_FORBIDDEN = 403
HTTP_NOT_FOUND = 404
HTTP_CONFLICT = 409
HTTP_UNPROCESSABLE = 422
ROSTOV = "Ростовский региональный филиал"
KRASNODAR = "Краснодарский региональный филиал"
KA_RF = "ka_rf@armka.local"
RKA = "rka@armka.local"
CURATOR = "curator_go@armka.local"
EXPERT = "expert@armka.local"
VERSIONS = "/consolidations/versions"
IN_WORK = (
ConsolidationStatus.INITIATED,
ConsolidationStatus.DOCUMENTS_PROCESSING,
ConsolidationStatus.DATA_CORRECTION,
)
async def _user(session: AsyncSession, email: str) -> AppUser:
return (await session.execute(select(AppUser).where(AppUser.email == email))).scalar_one()
async def _version(
session: AsyncSession,
*,
status: ConsolidationStatus = ConsolidationStatus.DATA_CORRECTION,
office_name: str = ROSTOV,
locked_by: str | None = KA_RF,
) -> tuple[ConsolidationVersion, list[LegalEntity]]:
"""Версия ГК филиала за 2024 Q3 из двух ЮЛ в статусе `status`; `locked_by` — владелец активной блокировки."""
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == office_name))).scalar_one()
group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office)
entities = [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(2)]
_, version = await make_consolidation(
session,
group=group,
year=2024,
quarter=PeriodQuarter.Q3,
members=entities,
initiator=await _user(session, KA_RF),
status=status,
)
if locked_by is not None:
await make_lock(session, version=version, user=await _user(session, locked_by))
return version, entities
def _status_url(version_id: uuid.UUID) -> str:
return f"{VERSIONS}/{version_id}/status"
async def _history(session: AsyncSession, version_id: uuid.UUID) -> list[ConsolidationStatusHistory]:
stmt = (
select(ConsolidationStatusHistory)
.where(ConsolidationStatusHistory.version_id == version_id)
.order_by(ConsolidationStatusHistory.changed_at, ConsolidationStatusHistory.id)
)
return list((await session.execute(stmt)).scalars())
async def _status_events(session: AsyncSession) -> list[AuditEvent]:
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_STATUS_CHANGE)
return list((await session.execute(stmt)).scalars())
async def _reload(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationVersion:
stmt = (
select(ConsolidationVersion)
.where(ConsolidationVersion.id == version_id)
.execution_options(populate_existing=True)
)
return (await session.execute(stmt)).scalar_one()
async def _active_locks(session: AsyncSession, version_id: uuid.UUID) -> int:
stmt = (
select(func.count())
.select_from(ConsolidationLock)
.where(ConsolidationLock.consolidation_version_id == version_id, ConsolidationLock.released_at.is_(None))
)
return (await session.execute(stmt)).scalar_one()
# --- аннулирование -------------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_cancel_by_lock_owner(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, _ = await _version(seeded, status=ConsolidationStatus.DATA_CORRECTION)
user = await _user(seeded, KA_RF)
client = await auth_client(KA_RF)
response = await client.post(
_status_url(version.id),
json={"to_status": "CANCELLED", "comment": "Дубль"},
headers={"User-Agent": "pytest-status"},
)
assert response.status_code == HTTP_OK, response.text
card = response.json()
assert (card["status"], card["is_in_work"], card["lock"]) == ("CANCELLED", False, None)
assert card["history"][-1]["from_status"] == "DATA_CORRECTION"
assert (card["history"][-1]["to_status"], card["history"][-1]["comment"]) == ("CANCELLED", "Дубль")
stored = await _reload(seeded, version.id)
assert (stored.status, stored.cancelled_reason, stored.is_in_work) == (
ConsolidationStatus.CANCELLED,
"Дубль",
False,
)
assert stored.updated_by == user.id
assert stored.updated_at is not None
assert await _active_locks(seeded, version.id) == 0
last = (await _history(seeded, version.id))[-1]
assert (last.from_status, last.to_status, last.changed_by, last.comment) == (
ConsolidationStatus.DATA_CORRECTION,
ConsolidationStatus.CANCELLED,
user.id,
"Дубль",
)
assert last.changed_at == stored.updated_at
[event] = await _status_events(seeded)
assert (event.section, event.object_type, event.object_id, event.consolidation_version_id) == (
AuditSection.CONSOLIDATION,
"consolidation_version",
version.id,
version.id,
)
assert event.user_id == user.id
assert (event.before_value, event.after_value, event.comment) == (
{"status": "DATA_CORRECTION"},
{"status": "CANCELLED"},
"Дубль",
)
# контекст запроса передан в аудит (ASGITransport отдаёт клиента 127.0.0.1)
assert (event.ip_address, event.user_agent) == ("127.0.0.1", "pytest-status")
@pytest.mark.integration
@pytest.mark.parametrize("status", IN_WORK)
async def test_cancel_allowed_from_every_in_work_status(
auth_client: AuthClientFactory, seeded: AsyncSession, status: ConsolidationStatus
) -> None:
version, _ = await _version(seeded, status=status)
client = await auth_client(KA_RF)
response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "Ошибка"})
assert response.status_code == HTTP_OK, response.text
assert response.json()["status"] == "CANCELLED"
@pytest.mark.integration
@pytest.mark.parametrize("payload", [{"to_status": "CANCELLED"}, {"to_status": "CANCELLED", "comment": " "}])
async def test_cancel_without_comment(
auth_client: AuthClientFactory, seeded: AsyncSession, payload: dict[str, Any]
) -> None:
version, _ = await _version(seeded)
client = await auth_client(KA_RF)
response = await client.post(_status_url(version.id), json=payload)
assert response.status_code == HTTP_UNPROCESSABLE
assert response.json()["detail"]["code"] == "COMMENT_REQUIRED"
assert (await _reload(seeded, version.id)).status == ConsolidationStatus.DATA_CORRECTION
assert await _active_locks(seeded, version.id) == 1
# --- возврат в INITIATED -------------------------------------------------------------------------------------------
@pytest.mark.integration
@pytest.mark.parametrize("status", [ConsolidationStatus.DOCUMENTS_PROCESSING, ConsolidationStatus.DATA_CORRECTION])
async def test_return_to_initiated_keeps_bound_documents(
auth_client: AuthClientFactory, seeded: AsyncSession, status: ConsolidationStatus
) -> None:
version, entities = await _version(seeded, status=status)
document = await make_confirmed_osv(
seeded, entity=entities[0], account="60", year=2024, quarter=PeriodQuarter.Q3, is_cumulative=True, rows=[]
)
seeded.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id))
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.post(_status_url(version.id), json={"to_status": "INITIATED"})
assert response.status_code == HTTP_OK, response.text
card = response.json()
assert (card["status"], card["is_in_work"]) == ("INITIATED", True)
assert card["lock"]["locked_by"] == str((await _user(seeded, KA_RF)).id) # блокировка остаётся
last = (await _history(seeded, version.id))[-1]
assert (last.from_status, last.to_status, last.comment) == (status, ConsolidationStatus.INITIATED, None)
bound = (
await seeded.execute(
select(ConsolidationDocument.document_id).where(
ConsolidationDocument.consolidation_version_id == version.id
)
)
).scalars()
assert list(bound) == [document.id]
[event] = await _status_events(seeded)
assert (event.before_value, event.after_value, event.comment) == (
{"status": status.value},
{"status": "INITIATED"},
None,
)
# --- запрещённые переходы ------------------------------------------------------------------------------------------
@pytest.mark.integration
@pytest.mark.parametrize(
("status", "to_status"),
[
(ConsolidationStatus.DATA_CORRECTION, "CONSOLIDATION_RF"), # переход Среза 7
(ConsolidationStatus.INITIATED, "INITIATED"), # повтор статуса
(ConsolidationStatus.DATA_CORRECTION, "DATA_CORRECTION"),
(ConsolidationStatus.INITIATED, "DOCUMENTS_PROCESSING"), # автоматический, не ручной
(ConsolidationStatus.DOCUMENTS_PROCESSING, "DATA_CORRECTION"),
(ConsolidationStatus.DATA_CORRECTION, "VERIFIED"),
],
)
async def test_transition_not_allowed(
auth_client: AuthClientFactory, seeded: AsyncSession, status: ConsolidationStatus, to_status: str
) -> None:
version, _ = await _version(seeded, status=status)
history_before = len(await _history(seeded, version.id))
client = await auth_client(KA_RF)
response = await client.post(_status_url(version.id), json={"to_status": to_status, "comment": "попытка"})
assert response.status_code == HTTP_CONFLICT, response.text
detail = response.json()["detail"]
assert (detail["code"], detail["from_status"], detail["to_status"]) == (
"TRANSITION_NOT_ALLOWED",
status.value,
to_status,
)
assert (await _reload(seeded, version.id)).status == status
assert len(await _history(seeded, version.id)) == history_before
assert await _status_events(seeded) == []
# --- политика редактируемости на ручной смене статуса --------------------------------------------------------------
@pytest.mark.integration
async def test_status_requires_lock(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, _ = await _version(seeded, locked_by=None)
client = await auth_client(KA_RF)
response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "x"})
assert response.status_code == HTTP_CONFLICT
assert response.json()["detail"]["code"] == "LOCK_REQUIRED"
@pytest.mark.integration
async def test_status_with_foreign_lock(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, _ = await _version(seeded, locked_by=RKA)
owner = await _user(seeded, RKA)
client = await auth_client(KA_RF)
response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "x"})
assert response.status_code == HTTP_CONFLICT
detail = response.json()["detail"]
assert (detail["code"], detail["locked_by"]) == ("LOCKED_BY_OTHER", str(owner.id))
assert "locked_at" in detail
@pytest.mark.integration
async def test_status_of_cancelled_version_is_not_editable(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Аннулированная версия: даже владелец прежней блокировки получает 409 `VERSION_NOT_EDITABLE`."""
version, _ = await _version(seeded, status=ConsolidationStatus.CANCELLED)
client = await auth_client(KA_RF)
response = await client.post(_status_url(version.id), json={"to_status": "INITIATED"})
assert response.status_code == HTTP_CONFLICT
assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE"
@pytest.mark.integration
async def test_status_of_foreign_version_is_not_found(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, _ = await _version(seeded, office_name=KRASNODAR, locked_by=None)
client = await auth_client(KA_RF)
body = {"to_status": "CANCELLED", "comment": "x"}
foreign = await client.post(_status_url(version.id), json=body)
missing = await client.post(_status_url(uuid.uuid4()), json=body)
assert foreign.status_code == HTTP_NOT_FOUND
assert foreign.json() == missing.json()
@pytest.mark.integration
@pytest.mark.parametrize("email", [CURATOR, EXPERT])
async def test_status_requires_consolidation_edit(
auth_client: AuthClientFactory, seeded: AsyncSession, email: str
) -> None:
version, _ = await _version(seeded)
client = await auth_client(email)
response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "x"})
assert response.status_code == HTTP_FORBIDDEN
@pytest.mark.integration
async def test_status_rejects_unknown_status(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, _ = await _version(seeded)
client = await auth_client(KA_RF)
response = await client.post(_status_url(version.id), json={"to_status": "DONE"})
assert response.status_code == HTTP_UNPROCESSABLE
# --- transition: единственное место смены статуса (автопереходы Task 6–7 зовут её же) ------------------------------
def test_manual_transitions_table() -> None:
cancelled, initiated = ConsolidationStatus.CANCELLED, ConsolidationStatus.INITIATED
assert consolidation_status.MANUAL_TRANSITIONS == {
ConsolidationStatus.INITIATED: frozenset({cancelled}),
ConsolidationStatus.DOCUMENTS_PROCESSING: frozenset({cancelled, initiated}),
ConsolidationStatus.DATA_CORRECTION: frozenset({cancelled, initiated}),
}
@pytest.mark.integration
async def test_transition_to_cancelled_releases_active_lock(
seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
version, _ = await _version(seeded, status=ConsolidationStatus.INITIATED)
user = await _user(seeded, RKA)
now = datetime.datetime(2026, 10, 6, 12, 0, tzinfo=datetime.UTC)
calls: list[tuple[uuid.UUID, datetime.datetime]] = []
original = consolidation_locks.release_active_lock
async def spy(session: AsyncSession, version_id: uuid.UUID, at: datetime.datetime) -> ConsolidationLock | None:
calls.append((version_id, at))
return await original(session, version_id, at)
monkeypatch.setattr(consolidation_locks, "release_active_lock", spy)
record = await consolidation_status.transition(
seeded,
AuditService(seeded),
version=version,
to=ConsolidationStatus.CANCELLED,
changed_by=user.id,
comment="Аннулирована",
ip_address="10.0.0.1",
user_agent="pytest",
now=now,
)
assert calls == [(version.id, now)]
assert await _active_locks(seeded, version.id) == 0
released = (
await seeded.execute(select(ConsolidationLock).where(ConsolidationLock.consolidation_version_id == version.id))
).scalar_one()
assert released.released_at == now
assert (record.from_status, record.to_status, record.changed_by, record.changed_at, record.comment) == (
ConsolidationStatus.INITIATED,
ConsolidationStatus.CANCELLED,
user.id,
now,
"Аннулирована",
)
assert (version.status, version.is_in_work, version.cancelled_reason) == (
ConsolidationStatus.CANCELLED,
False,
"Аннулирована",
)
assert (version.updated_by, version.updated_at) == (user.id, now)
[event] = await _status_events(seeded)
assert (event.user_id, event.ip_address, event.user_agent) == (user.id, "10.0.0.1", "pytest")
@pytest.mark.integration
async def test_transition_other_than_cancelled_keeps_lock(
seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Автоматический переход (Task 6–7) и возврат в `INITIATED` блокировку не снимают; `user_id` — `changed_by`."""
version, _ = await _version(seeded, status=ConsolidationStatus.INITIATED)
confirmer = await _user(seeded, RKA)
calls: list[uuid.UUID] = []
async def spy(_session: AsyncSession, version_id: uuid.UUID, _at: datetime.datetime) -> None:
calls.append(version_id)
monkeypatch.setattr(consolidation_locks, "release_active_lock", spy)
await consolidation_status.transition(
seeded,
AuditService(seeded),
version=version,
to=ConsolidationStatus.DATA_CORRECTION,
changed_by=confirmer.id,
comment=None,
now=datetime.datetime.now(datetime.UTC),
)
assert calls == []
assert await _active_locks(seeded, version.id) == 1
assert (version.status, version.is_in_work, version.cancelled_reason) == (
ConsolidationStatus.DATA_CORRECTION,
True,
None,
)
[event] = await _status_events(seeded)
assert (event.user_id, event.ip_address, event.before_value, event.after_value) == (
confirmer.id,
None,
{"status": "INITIATED"},
{"status": "DATA_CORRECTION"},
)