183 lines
6.7 KiB
Python
183 lines
6.7 KiB
Python
from __future__ import annotations
|
|
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
|
from sqlmodel import SQLModel
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
from app.api import approvals as approvals_api
|
|
from app.models.boards import Board
|
|
from app.models.organizations import Organization
|
|
from app.models.tasks import Task
|
|
from app.schemas.approvals import ApprovalCreate, ApprovalUpdate
|
|
|
|
|
|
async def _make_engine() -> AsyncEngine:
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
async with engine.connect() as conn, conn.begin():
|
|
await conn.run_sync(SQLModel.metadata.create_all)
|
|
return engine
|
|
|
|
|
|
async def _make_session(engine: AsyncEngine) -> AsyncSession:
|
|
return AsyncSession(engine, expire_on_commit=False)
|
|
|
|
|
|
async def _seed_board_with_tasks(
|
|
session: AsyncSession,
|
|
*,
|
|
task_count: int = 2,
|
|
) -> tuple[Board, list[UUID]]:
|
|
org_id = uuid4()
|
|
board = Board(id=uuid4(), organization_id=org_id, name="b", slug="b")
|
|
task_ids = [uuid4() for _ in range(task_count)]
|
|
|
|
session.add(Organization(id=org_id, name=f"org-{org_id}"))
|
|
session.add(board)
|
|
for task_id in task_ids:
|
|
session.add(Task(id=task_id, board_id=board.id, title=f"task-{task_id}"))
|
|
await session.commit()
|
|
|
|
return board, task_ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_approval_rejects_duplicate_pending_for_same_task() -> None:
|
|
engine = await _make_engine()
|
|
try:
|
|
async with await _make_session(engine) as session:
|
|
board, task_ids = await _seed_board_with_tasks(session, task_count=1)
|
|
task_id = task_ids[0]
|
|
created = await approvals_api.create_approval(
|
|
payload=ApprovalCreate(
|
|
action_type="task.execute",
|
|
task_id=task_id,
|
|
payload={"reason": "Initial execution needs confirmation."},
|
|
confidence=80,
|
|
status="pending",
|
|
),
|
|
board=board,
|
|
session=session,
|
|
)
|
|
assert created.task_titles == [f"task-{task_id}"]
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await approvals_api.create_approval(
|
|
payload=ApprovalCreate(
|
|
action_type="task.retry",
|
|
task_id=task_id,
|
|
payload={"reason": "Retry should still be gated."},
|
|
confidence=77,
|
|
status="pending",
|
|
),
|
|
board=board,
|
|
session=session,
|
|
)
|
|
|
|
assert exc.value.status_code == 409
|
|
detail = exc.value.detail
|
|
assert isinstance(detail, dict)
|
|
assert detail["message"] == "Each task can have only one pending approval."
|
|
assert len(detail["conflicts"]) == 1
|
|
assert detail["conflicts"][0]["task_id"] == str(task_id)
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_approval_rejects_pending_conflict_from_linked_task_ids() -> None:
|
|
engine = await _make_engine()
|
|
try:
|
|
async with await _make_session(engine) as session:
|
|
board, task_ids = await _seed_board_with_tasks(session, task_count=2)
|
|
task_a, task_b = task_ids
|
|
created = await approvals_api.create_approval(
|
|
payload=ApprovalCreate(
|
|
action_type="task.batch_execute",
|
|
task_ids=[task_a, task_b],
|
|
payload={"reason": "Batch operation requires sign-off."},
|
|
confidence=85,
|
|
status="pending",
|
|
),
|
|
board=board,
|
|
session=session,
|
|
)
|
|
assert created.task_titles == [f"task-{task_a}", f"task-{task_b}"]
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await approvals_api.create_approval(
|
|
payload=ApprovalCreate(
|
|
action_type="task.execute",
|
|
task_id=task_b,
|
|
payload={"reason": "Single task overlaps with pending batch."},
|
|
confidence=70,
|
|
status="pending",
|
|
),
|
|
board=board,
|
|
session=session,
|
|
)
|
|
|
|
assert exc.value.status_code == 409
|
|
detail = exc.value.detail
|
|
assert isinstance(detail, dict)
|
|
assert detail["message"] == "Each task can have only one pending approval."
|
|
assert len(detail["conflicts"]) == 1
|
|
assert detail["conflicts"][0]["task_id"] == str(task_b)
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_approval_rejects_reopening_to_pending_with_existing_pending() -> None:
|
|
engine = await _make_engine()
|
|
try:
|
|
async with await _make_session(engine) as session:
|
|
board, task_ids = await _seed_board_with_tasks(session, task_count=1)
|
|
task_id = task_ids[0]
|
|
pending = await approvals_api.create_approval(
|
|
payload=ApprovalCreate(
|
|
action_type="task.execute",
|
|
task_id=task_id,
|
|
payload={"reason": "Primary pending approval is active."},
|
|
confidence=83,
|
|
status="pending",
|
|
),
|
|
board=board,
|
|
session=session,
|
|
)
|
|
resolved = await approvals_api.create_approval(
|
|
payload=ApprovalCreate(
|
|
action_type="task.review",
|
|
task_id=task_id,
|
|
payload={"reason": "Review decision completed earlier."},
|
|
confidence=90,
|
|
status="approved",
|
|
),
|
|
board=board,
|
|
session=session,
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await approvals_api.update_approval(
|
|
approval_id=resolved.id, # type: ignore[arg-type]
|
|
payload=ApprovalUpdate(status="pending"),
|
|
board=board,
|
|
session=session,
|
|
)
|
|
|
|
assert exc.value.status_code == 409
|
|
detail = exc.value.detail
|
|
assert isinstance(detail, dict)
|
|
assert detail["message"] == "Each task can have only one pending approval."
|
|
assert detail["conflicts"] == [
|
|
{
|
|
"task_id": str(task_id),
|
|
"approval_id": str(pending.id),
|
|
},
|
|
]
|
|
finally:
|
|
await engine.dispose()
|