2026-06-15 20:47:41 +02:00
|
|
|
"""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 == []
|
2026-07-02 21:05:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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,
|
|
|
|
|
}
|
|
|
|
|
]
|