Files
roboco/tests/integration/test_secretary_routes.py
T

196 lines
6.2 KiB
Python
Raw Normal View History

"""roboco.api.routes.secretary — role gates + directive flow (direct-call style)."""
from __future__ import annotations
from http import HTTPStatus
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from fastapi import HTTPException
from roboco.api.routes import secretary as sec_route
from roboco.api.schemas.secretary import DirectiveDecision, DirectiveSubmit
from roboco.models import AgentRole
from roboco.models.permissions import AgentContext
from roboco.services.base import ConflictError
_ROW = object()
_DIRECTIVE_DICT: dict[str, Any] = {
"id": "11111111-1111-1111-1111-111111111111",
"kind": "relay_message",
"status": "executed",
"payload": {},
"requested_by": "22222222-2222-2222-2222-222222222222",
"requested_at": None,
"decided_by": None,
"decided_at": None,
"result": "posted to #all-hands",
}
def _agent(role: AgentRole) -> AgentContext:
return AgentContext(agent_id=uuid4(), role=role, team=None)
def _db() -> MagicMock:
db = MagicMock()
db.commit = AsyncMock()
return db
class _FakeService:
def __init__(self, *, exc: Exception | None = None) -> None:
self._exc = exc
async def submit_directive(self, _kind: Any, _payload: Any, _by: Any) -> object:
if self._exc is not None:
raise self._exc
return _ROW
async def confirm_directive(self, _directive_id: Any, _by: Any) -> object:
if self._exc is not None:
raise self._exc
return _ROW
async def reject_directive(
self, _directive_id: Any, _by: Any, _reason: Any
) -> object:
if self._exc is not None:
raise self._exc
return _ROW
async def list_directives(self, _status: Any = None) -> list[object]:
return [_ROW]
async def read_company_state(self) -> dict[str, Any]:
return {
"goals": {},
"task_counts": {},
"pending_pitches": [],
"pending_directives": [],
}
def to_dict(self, _row: object) -> dict[str, Any]:
return dict(_DIRECTIVE_DICT)
def _install(monkeypatch: pytest.MonkeyPatch, service: _FakeService) -> None:
monkeypatch.setattr(sec_route, "get_secretary_service", lambda _db: service)
@pytest.mark.asyncio
async def test_submit_forbidden_for_developer() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.submit_directive(
DirectiveSubmit(kind="relay_message"), _db(), _agent(AgentRole.DEVELOPER)
)
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_submit_bad_kind_422() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.submit_directive(
DirectiveSubmit(kind="bogus"), _db(), _agent(AgentRole.SECRETARY)
)
assert exc.value.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_submit_success(monkeypatch: pytest.MonkeyPatch) -> None:
db = _db()
_install(monkeypatch, _FakeService())
resp = await sec_route.submit_directive(
DirectiveSubmit(
kind="relay_message", payload={"channel": "all-hands", "text": "hi"}
),
db,
_agent(AgentRole.SECRETARY),
)
assert resp.status == "executed"
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_confirm_forbidden_for_secretary() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.confirm_directive(uuid4(), _db(), _agent(AgentRole.SECRETARY))
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_confirm_success(monkeypatch: pytest.MonkeyPatch) -> None:
db = _db()
_install(monkeypatch, _FakeService())
resp = await sec_route.confirm_directive(uuid4(), db, _agent(AgentRole.CEO))
assert resp.id == _DIRECTIVE_DICT["id"]
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_confirm_conflict_409(monkeypatch: pytest.MonkeyPatch) -> None:
_install(monkeypatch, _FakeService(exc=ConflictError("already decided")))
with pytest.raises(HTTPException) as exc:
await sec_route.confirm_directive(uuid4(), _db(), _agent(AgentRole.CEO))
assert exc.value.status_code == HTTPStatus.CONFLICT
@pytest.mark.asyncio
async def test_list_forbidden_for_secretary() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.list_directives(_db(), _agent(AgentRole.SECRETARY))
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_reject_success(monkeypatch: pytest.MonkeyPatch) -> None:
db = _db()
_install(monkeypatch, _FakeService())
resp = await sec_route.reject_directive(
uuid4(), DirectiveDecision(reason="no"), db, _agent(AgentRole.CEO)
)
assert resp.id == _DIRECTIVE_DICT["id"]
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_state_allows_secretary(monkeypatch: pytest.MonkeyPatch) -> None:
_install(monkeypatch, _FakeService())
resp = await sec_route.read_state(_db(), _agent(AgentRole.SECRETARY))
assert resp.pending_pitches == []
@pytest.mark.asyncio
async def test_search_tasks_forbidden_for_developer() -> None:
with pytest.raises(HTTPException) as exc:
await sec_route.search_tasks(_db(), _agent(AgentRole.DEVELOPER), q="greeting")
assert exc.value.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_search_tasks_returns_compact_rows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The CEO refers to tasks by NAME in the Secretary chat — the search
resolves names to ids so a directive can target the right task."""
row = MagicMock()
row.id = uuid4()
row.title = "Rework the greeting banner"
row.status = "pending"
row.team = "backend"
row.priority = 2
task_svc = MagicMock()
task_svc.search_tasks = AsyncMock(return_value=[row])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _db: task_svc)
out = await sec_route.search_tasks(_db(), _agent(AgentRole.SECRETARY), q="greeting")
assert out == [
{
"id": str(row.id),
"title": "Rework the greeting banner",
"status": "pending",
"team": "backend",
"priority": 2,
}
]