MembersIn.legal_entity_ids ≤ 500, StatusIn.comment ≤ 1000, OsvFilters.q ≤ 500 (422 со списком ошибок); lock_visible_version проверяет видимость версии до FOR UPDATE. Спека consolidations-api: требование «Границы входных данных». Refs #21 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
488 lines
23 KiB
Python
488 lines
23 KiB
Python
"""Блокировка версии консолидации: взять в работу, перехват, снятие (spec consolidations-api «Взять в работу,
|
||
перехват, снятие»; audit-trail `CONSOLIDATION_LOCK`, `CONSOLIDATION_LOCK_TAKEOVER`; design D-7, D-9; Review Focus 4).
|
||
|
||
Гонки — на `committed_*` (настоящие параллельные транзакции): два одновременных `POST …/lock` и перехват во время
|
||
правки версии владельцем (правка держит `consolidation_version FOR UPDATE`, перехват ждёт её commit).
|
||
"""
|
||
|
||
import asyncio
|
||
import datetime
|
||
import uuid
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from sqlalchemy import func, select, text
|
||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||
|
||
from src.models.access import AppUser
|
||
from src.models.audit_ex import AuditEvent
|
||
from src.models.consolidation import Consolidation, ConsolidationLock, ConsolidationMember, ConsolidationVersion
|
||
from src.models.directories import CompanyGroup, RegionalOffice
|
||
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter
|
||
from src.repositories import consolidations as repo
|
||
from src.services import consolidation_status
|
||
from src.services.audit import AuditService
|
||
from tests.conftest import AuthClientFactory
|
||
from tests.factories import make_company_group, make_consolidation, make_legal_entity, make_lock
|
||
|
||
HTTP_OK = 200
|
||
HTTP_CREATED = 201
|
||
HTTP_NO_CONTENT = 204
|
||
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" # «Б», тот же филиал
|
||
EXPERT = "expert@armka.local"
|
||
VERSIONS = "/consolidations/versions"
|
||
LOCK_WAIT_SECONDS = 5
|
||
MAX_REASON = 1000
|
||
|
||
|
||
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,
|
||
*,
|
||
office_name: str = ROSTOV,
|
||
status: ConsolidationStatus = ConsolidationStatus.INITIATED,
|
||
) -> ConsolidationVersion:
|
||
"""Версия консолидации ГК филиала `office_name` за 2024 Q3 из двух ЮЛ (инициатор — ka_rf)."""
|
||
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,
|
||
)
|
||
return version
|
||
|
||
|
||
def _lock_url(version_id: uuid.UUID) -> str:
|
||
return f"{VERSIONS}/{version_id}/lock"
|
||
|
||
|
||
async def _locks(session: AsyncSession, version_id: uuid.UUID) -> list[ConsolidationLock]:
|
||
stmt = (
|
||
select(ConsolidationLock)
|
||
.where(ConsolidationLock.consolidation_version_id == version_id)
|
||
.order_by(ConsolidationLock.locked_at)
|
||
.execution_options(populate_existing=True)
|
||
)
|
||
return list((await session.execute(stmt)).scalars())
|
||
|
||
|
||
async def _events(session: AsyncSession, action: AuditAction) -> list[AuditEvent]:
|
||
stmt = select(AuditEvent).where(AuditEvent.action == action).order_by(AuditEvent.occurred_at, AuditEvent.id)
|
||
return list((await session.execute(stmt)).scalars())
|
||
|
||
|
||
# --- взять в работу ------------------------------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_take_free_version_creates_lock(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
version = await _version(seeded)
|
||
user = await _user(seeded, KA_RF)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(_lock_url(version.id))
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
body = response.json()
|
||
assert (body["locked_by"], body["locked_by_name"]) == (str(user.id), user.full_name)
|
||
assert (body["taken_over_from"], body["takeover_reason"]) == (None, None)
|
||
[lock] = await _locks(seeded, version.id)
|
||
assert (lock.locked_by, lock.released_at) == (user.id, None)
|
||
assert datetime.datetime.fromisoformat(body["locked_at"]) == lock.locked_at
|
||
[event] = await _events(seeded, AuditAction.CONSOLIDATION_LOCK)
|
||
assert (event.section, event.object_type, event.object_id) == (
|
||
AuditSection.CONSOLIDATION,
|
||
"consolidation_version",
|
||
version.id,
|
||
)
|
||
assert (event.consolidation_version_id, event.user_id) == (version.id, user.id)
|
||
assert event.before_value is None
|
||
assert event.after_value == {"locked_by": str(user.id), "released": False}
|
||
card = (await client.get(f"{VERSIONS}/{version.id}")).json()
|
||
assert card["lock"]["locked_by"] == str(user.id)
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_take_own_lock_returns_it_unchanged(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
version = await _version(seeded)
|
||
client = await auth_client(KA_RF)
|
||
first = await client.post(_lock_url(version.id))
|
||
|
||
again = await client.post(_lock_url(version.id), json={"takeover_reason": "повтор"})
|
||
|
||
assert again.status_code == HTTP_OK, again.text
|
||
assert again.json() == first.json()
|
||
assert len(await _locks(seeded, version.id)) == 1
|
||
assert len(await _events(seeded, AuditAction.CONSOLIDATION_LOCK)) == 1
|
||
assert await _events(seeded, AuditAction.CONSOLIDATION_LOCK_TAKEOVER) == []
|
||
|
||
|
||
@pytest.mark.integration
|
||
@pytest.mark.parametrize("payload", [None, {}, {"takeover_reason": None}, {"takeover_reason": " "}])
|
||
async def test_take_foreign_lock_without_reason_is_conflict(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, payload: dict[str, Any] | None
|
||
) -> None:
|
||
version = await _version(seeded)
|
||
owner = await _user(seeded, KA_RF)
|
||
lock = await make_lock(seeded, version=version, user=owner)
|
||
client = await auth_client(RKA)
|
||
|
||
response = await client.post(_lock_url(version.id), json=payload)
|
||
|
||
assert response.status_code == HTTP_CONFLICT, response.text
|
||
detail = response.json()["detail"]
|
||
assert detail["code"] == "LOCKED_BY_OTHER"
|
||
assert (detail["locked_by"], detail["locked_by_name"]) == (str(owner.id), owner.full_name)
|
||
assert datetime.datetime.fromisoformat(detail["locked_at"]) == lock.locked_at
|
||
[still] = await _locks(seeded, version.id)
|
||
assert still.released_at is None
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_lock_does_not_expire(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
"""ASSUMPTION(OQ-119): блокировка двухлетней давности по-прежнему чужая."""
|
||
version = await _version(seeded)
|
||
lock = await make_lock(seeded, version=version, user=await _user(seeded, KA_RF))
|
||
lock.locked_at = lock.locked_at - datetime.timedelta(days=730)
|
||
await seeded.flush()
|
||
client = await auth_client(RKA)
|
||
|
||
response = await client.post(_lock_url(version.id))
|
||
|
||
assert response.status_code == HTTP_CONFLICT
|
||
assert response.json()["detail"]["code"] == "LOCKED_BY_OTHER"
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_takeover_with_reason(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
version = await _version(seeded)
|
||
owner = await _user(seeded, KA_RF)
|
||
taker = await _user(seeded, RKA)
|
||
await make_lock(seeded, version=version, user=owner)
|
||
client = await auth_client(RKA)
|
||
|
||
response = await client.post(_lock_url(version.id), json={"takeover_reason": "Отпуск"})
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
body = response.json()
|
||
assert (body["locked_by"], body["locked_by_name"]) == (str(taker.id), taker.full_name)
|
||
assert (body["taken_over_from"], body["takeover_reason"]) == (str(owner.id), "Отпуск")
|
||
old, new = await _locks(seeded, version.id)
|
||
assert old.locked_by == owner.id
|
||
assert old.released_at is not None
|
||
assert (new.locked_by, new.released_at, new.taken_over_from, new.takeover_reason) == (
|
||
taker.id,
|
||
None,
|
||
owner.id,
|
||
"Отпуск",
|
||
)
|
||
assert old.released_at == new.locked_at # одно `now` на операцию
|
||
[event] = await _events(seeded, AuditAction.CONSOLIDATION_LOCK_TAKEOVER)
|
||
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 == taker.id
|
||
assert event.before_value == {"locked_by": str(owner.id)}
|
||
assert event.after_value == {"locked_by": str(taker.id)}
|
||
assert event.comment == "Отпуск"
|
||
assert await _events(seeded, AuditAction.CONSOLIDATION_LOCK) == []
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_takeover_reason_longer_than_limit_is_rejected(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||
) -> None:
|
||
version = await _version(seeded)
|
||
await make_lock(seeded, version=version, user=await _user(seeded, KA_RF))
|
||
client = await auth_client(RKA)
|
||
|
||
too_long = await client.post(_lock_url(version.id), json={"takeover_reason": "я" * (MAX_REASON + 1)})
|
||
at_limit = await client.post(_lock_url(version.id), json={"takeover_reason": "я" * MAX_REASON})
|
||
|
||
assert too_long.status_code == HTTP_UNPROCESSABLE
|
||
assert at_limit.status_code == HTTP_CREATED, at_limit.text
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_take_cancelled_version_is_not_editable(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
version = await _version(seeded, status=ConsolidationStatus.CANCELLED)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(_lock_url(version.id))
|
||
|
||
assert response.status_code == HTTP_CONFLICT
|
||
assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE"
|
||
assert await _locks(seeded, version.id) == []
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_take_version_outside_in_work_but_not_cancelled_is_allowed(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||
) -> None:
|
||
"""«В любом статусе, кроме CANCELLED»: версию на маршруте согласования (Срез 7) можно взять в работу."""
|
||
version = await _version(seeded, status=ConsolidationStatus.CONSOLIDATION_RF)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(_lock_url(version.id))
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_lock_routes_hide_foreign_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
version = await _version(seeded, office_name=KRASNODAR)
|
||
client = await auth_client(KA_RF)
|
||
|
||
for method in ("post", "delete"):
|
||
foreign = await client.request(method, _lock_url(version.id))
|
||
missing = await client.request(method, _lock_url(uuid.uuid4()))
|
||
assert foreign.status_code == HTTP_NOT_FOUND
|
||
assert foreign.json() == missing.json()
|
||
assert await _locks(seeded, version.id) == []
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_lock_routes_require_consolidation_edit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
version = await _version(seeded)
|
||
client = await auth_client(EXPERT)
|
||
|
||
assert (await client.post(_lock_url(version.id))).status_code == HTTP_FORBIDDEN
|
||
assert (await client.delete(_lock_url(version.id))).status_code == HTTP_FORBIDDEN
|
||
|
||
|
||
# --- снятие --------------------------------------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_release_own_lock(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
version = await _version(seeded)
|
||
user = await _user(seeded, KA_RF)
|
||
await make_lock(seeded, version=version, user=user)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.delete(_lock_url(version.id))
|
||
|
||
assert response.status_code == HTTP_NO_CONTENT, response.text
|
||
assert response.content == b""
|
||
[lock] = await _locks(seeded, version.id)
|
||
assert lock.released_at is not None
|
||
[event] = await _events(seeded, AuditAction.CONSOLIDATION_LOCK)
|
||
assert (event.object_id, event.consolidation_version_id, event.user_id) == (version.id, version.id, user.id)
|
||
assert event.after_value == {"locked_by": str(user.id), "released": True}
|
||
assert (await client.get(f"{VERSIONS}/{version.id}")).json()["lock"] is None
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_release_foreign_lock_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
version = await _version(seeded)
|
||
owner = await _user(seeded, KA_RF)
|
||
await make_lock(seeded, version=version, user=owner)
|
||
client = await auth_client(RKA)
|
||
|
||
response = await client.delete(_lock_url(version.id))
|
||
|
||
assert response.status_code == HTTP_CONFLICT
|
||
detail = response.json()["detail"]
|
||
assert (detail["code"], detail["locked_by"]) == ("NOT_LOCK_OWNER", str(owner.id))
|
||
[lock] = await _locks(seeded, version.id)
|
||
assert lock.released_at is None
|
||
assert await _events(seeded, AuditAction.CONSOLIDATION_LOCK) == []
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_release_without_active_lock_is_noop(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
version = await _version(seeded)
|
||
await make_lock(seeded, version=version, user=await _user(seeded, KA_RF), released=True)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.delete(_lock_url(version.id))
|
||
|
||
assert response.status_code == HTTP_NO_CONTENT
|
||
assert await _events(seeded, AuditAction.CONSOLIDATION_LOCK) == []
|
||
|
||
|
||
# --- гонки (настоящие параллельные транзакции) ---------------------------------------------------------------------
|
||
|
||
|
||
async def _committed_version(maker: async_sessionmaker[AsyncSession]) -> uuid.UUID:
|
||
async with maker() as session, session.begin():
|
||
return (await _version(session)).id
|
||
|
||
|
||
async def _wait_for_lock_waiter(maker: async_sessionmaker[AsyncSession]) -> None:
|
||
"""Ждёт, пока какой-то сеанс БД встанет в ожидание блокировки строки."""
|
||
deadline = asyncio.get_running_loop().time() + LOCK_WAIT_SECONDS
|
||
while asyncio.get_running_loop().time() < deadline:
|
||
async with maker() as session:
|
||
waiting = (
|
||
await session.execute(
|
||
text(
|
||
"SELECT count(*) FROM pg_stat_activity "
|
||
"WHERE datname = current_database() AND wait_event_type = 'Lock'"
|
||
)
|
||
)
|
||
).scalar_one()
|
||
if waiting:
|
||
return
|
||
await asyncio.sleep(0.05)
|
||
raise AssertionError("запрос не встал в ожидание блокировки версии")
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_two_simultaneous_takes_one_wins(
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory
|
||
) -> None:
|
||
"""Два `POST …/lock` свободной версии одновременно: у одного 201, у другого 409 `LOCKED_BY_OTHER`."""
|
||
version_id = await _committed_version(committed_sessionmaker)
|
||
first, second = await committed_client(KA_RF), await committed_client(RKA)
|
||
|
||
responses = await asyncio.wait_for(
|
||
asyncio.gather(first.post(_lock_url(version_id)), second.post(_lock_url(version_id))), LOCK_WAIT_SECONDS
|
||
)
|
||
|
||
assert sorted(response.status_code for response in responses) == [HTTP_CREATED, HTTP_CONFLICT]
|
||
winner = next(response for response in responses if response.status_code == HTTP_CREATED)
|
||
loser = next(response for response in responses if response.status_code == HTTP_CONFLICT)
|
||
assert loser.json()["detail"]["code"] == "LOCKED_BY_OTHER"
|
||
assert loser.json()["detail"]["locked_by"] == winner.json()["locked_by"]
|
||
async with committed_sessionmaker() as session:
|
||
active = (
|
||
await session.execute(
|
||
select(func.count())
|
||
.select_from(ConsolidationLock)
|
||
.where(
|
||
ConsolidationLock.consolidation_version_id == version_id, ConsolidationLock.released_at.is_(None)
|
||
)
|
||
)
|
||
).scalar_one()
|
||
assert active == 1
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_takeover_waits_for_owner_edit_then_owner_loses_lock(
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory
|
||
) -> None:
|
||
"""Review Focus 4: владелец А правит периметр (`consolidation_version FOR UPDATE`), Б в это время перехватывает.
|
||
|
||
Перехват ждёт транзакцию А; после её commit правка А зафиксирована, Б — владелец, следующая правка А —
|
||
409 `LOCKED_BY_OTHER`. Этот сценарий ждал бы и без `FOR UPDATE` версии в `take` (вставка блокировки берёт FK
|
||
`KEY SHARE` на версию); порядок D-9 различает `test_takeover_during_cancellation_sees_cancelled_version`.
|
||
"""
|
||
async with committed_sessionmaker() as setup, setup.begin():
|
||
version = await _version(setup)
|
||
owner = await _user(setup, KA_RF)
|
||
await make_lock(setup, version=version, user=owner)
|
||
consolidation = await setup.get(Consolidation, version.consolidation_id)
|
||
assert consolidation is not None
|
||
group = await setup.get(CompanyGroup, consolidation.company_group_id)
|
||
assert group is not None
|
||
newcomer_id = (await make_legal_entity(setup, name="ООО Новый член", group=group)).id
|
||
version_id = version.id
|
||
taker_client = await committed_client(RKA)
|
||
owner_client = await committed_client(KA_RF)
|
||
|
||
async with committed_sessionmaker() as editor, editor.begin():
|
||
# правка периметра владельцем: версия под FOR UPDATE первой (D-9), затем строки версии
|
||
locked = await repo.lock_version(editor, version_id)
|
||
assert locked is not None
|
||
editor.add(ConsolidationMember(consolidation_version_id=version_id, legal_entity_id=newcomer_id))
|
||
await editor.flush()
|
||
takeover = asyncio.create_task(taker_client.post(_lock_url(version_id), json={"takeover_reason": "Отпуск"}))
|
||
await _wait_for_lock_waiter(committed_sessionmaker)
|
||
assert not takeover.done()
|
||
response = await asyncio.wait_for(takeover, LOCK_WAIT_SECONDS)
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
assert response.json()["taken_over_from"] == str(owner.id)
|
||
async with committed_sessionmaker() as session:
|
||
members = (
|
||
await session.execute(
|
||
select(ConsolidationMember.legal_entity_id).where(
|
||
ConsolidationMember.consolidation_version_id == version_id
|
||
)
|
||
)
|
||
).scalars()
|
||
assert newcomer_id in set(members) # правка владельца зафиксирована до перехвата
|
||
next_edit = await owner_client.post(
|
||
f"{VERSIONS}/{version_id}/status", json={"to_status": "CANCELLED", "comment": "x"}
|
||
)
|
||
assert next_edit.status_code == HTTP_CONFLICT, next_edit.text
|
||
assert next_edit.json()["detail"]["code"] == "LOCKED_BY_OTHER"
|
||
assert next_edit.json()["detail"]["locked_by"] == response.json()["locked_by"]
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_takeover_during_cancellation_sees_cancelled_version(
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory
|
||
) -> None:
|
||
"""Перехват читает статус и блокировку только ПОСЛЕ `FOR UPDATE` версии (D-9).
|
||
|
||
Владелец аннулирует версию (транзакция держит версию и снимает свою блокировку); перехват, пришедший в это время,
|
||
ждёт и после commit видит `CANCELLED` → 409 `VERSION_NOT_EDITABLE`. Прочитай он состояние до ожидания —
|
||
перехватил бы аннулированную версию (вставку блокировки задержал бы лишь FK, а не проверка статуса).
|
||
"""
|
||
async with committed_sessionmaker() as setup, setup.begin():
|
||
version = await _version(setup)
|
||
owner = await _user(setup, KA_RF)
|
||
await make_lock(setup, version=version, user=owner)
|
||
version_id, owner_id = version.id, owner.id
|
||
taker_client = await committed_client(RKA)
|
||
|
||
async with committed_sessionmaker() as editor, editor.begin():
|
||
locked = await repo.lock_version(editor, version_id)
|
||
assert locked is not None
|
||
await consolidation_status.transition(
|
||
editor,
|
||
AuditService(editor),
|
||
version=locked,
|
||
to=ConsolidationStatus.CANCELLED,
|
||
changed_by=owner_id,
|
||
comment="Дубль",
|
||
now=datetime.datetime.now(datetime.UTC),
|
||
)
|
||
takeover = asyncio.create_task(taker_client.post(_lock_url(version_id), json={"takeover_reason": "Отпуск"}))
|
||
await _wait_for_lock_waiter(committed_sessionmaker)
|
||
assert not takeover.done()
|
||
response = await asyncio.wait_for(takeover, LOCK_WAIT_SECONDS)
|
||
|
||
assert response.status_code == HTTP_CONFLICT, response.text
|
||
assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE"
|
||
async with committed_sessionmaker() as session:
|
||
assert all(lock.released_at is not None for lock in await _locks(session, version_id))
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_foreign_version_is_hidden_without_waiting_for_row_lock(
|
||
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory
|
||
) -> None:
|
||
"""M-6: видимость проверяется до `FOR UPDATE` — чужая версия отвечает 404 сразу, даже когда её строку держит
|
||
правка владельца (иначе невидимый пользователь ждал бы чужую транзакцию и мог влиять на её длительность)."""
|
||
async with committed_sessionmaker() as setup, setup.begin():
|
||
version_id = (await _version(setup, office_name=KRASNODAR)).id
|
||
client = await committed_client(KA_RF)
|
||
|
||
async with committed_sessionmaker() as holder, holder.begin():
|
||
assert await repo.lock_version(holder, version_id) is not None
|
||
response = await asyncio.wait_for(client.post(_lock_url(version_id)), LOCK_WAIT_SECONDS)
|
||
|
||
assert response.status_code == HTTP_NOT_FOUND
|