mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(gateway): notify(target, text, scope?) for PMs and Board
Pre-gateway PMs/Board sent formal notifications (require ack); gateway had say/dm only. Now PMs and Board can issue ack-required notifications via NotificationService through the standard envelope path.
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
"""Tests for ContentActions.notify — formal ack-required notifications.
|
||||
|
||||
Pre-gateway, PMs and Board could issue formal notifications requiring
|
||||
acknowledgment via NotificationService. Gateway only had say/dm
|
||||
(informal). This verb fills the gap by composing NotificationService
|
||||
into the standard envelope path, role-gated to PMs and Board only
|
||||
(content tools share one router, so the role check lives in the verb).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
|
||||
|
||||
|
||||
def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
|
||||
if "task" in overrides:
|
||||
task = overrides["task"]
|
||||
else:
|
||||
task = AsyncMock()
|
||||
task.get_active_task_for_agent.return_value = None
|
||||
task.agent_for.return_value = MagicMock(role="cell_pm")
|
||||
|
||||
git = overrides.get("git", AsyncMock())
|
||||
messaging = overrides.get("messaging", AsyncMock())
|
||||
a2a = overrides.get("a2a", AsyncMock())
|
||||
journal = overrides.get("journal", AsyncMock())
|
||||
workspace = overrides.get("workspace", AsyncMock())
|
||||
notifications = overrides.get("notifications", AsyncMock())
|
||||
return ContentActionsDeps(
|
||||
task=task,
|
||||
git=git,
|
||||
messaging=messaging,
|
||||
a2a=a2a,
|
||||
journal=journal,
|
||||
workspace=workspace,
|
||||
notifications=notifications,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_pm_creates_ack_required_notification() -> None:
|
||||
"""Cell PM calls notify(); NotificationService.send_ack_notification fired."""
|
||||
agent_id = uuid4()
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get_active_task_for_agent.return_value = None
|
||||
task_svc.agent_for.return_value = MagicMock(role="cell_pm")
|
||||
notif_svc = AsyncMock()
|
||||
|
||||
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
||||
ca = ContentActions(deps)
|
||||
|
||||
env = await ca.notify(
|
||||
agent_id=agent_id,
|
||||
target="be-dev-1",
|
||||
text="Please review the new acceptance criteria before resuming.",
|
||||
)
|
||||
body = env.as_dict()
|
||||
|
||||
assert body["error"] is None
|
||||
assert body["status"] == "sent"
|
||||
notif_svc.send_ack_notification.assert_awaited_once()
|
||||
call_kwargs = notif_svc.send_ack_notification.call_args.kwargs
|
||||
assert call_kwargs["from_agent"] == agent_id
|
||||
assert call_kwargs["to_agent"] == "be-dev-1"
|
||||
assert "acceptance criteria" in call_kwargs["body"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_main_pm_succeeds() -> None:
|
||||
"""Main PM is also allowed."""
|
||||
agent_id = uuid4()
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get_active_task_for_agent.return_value = None
|
||||
task_svc.agent_for.return_value = MagicMock(role="main_pm")
|
||||
notif_svc = AsyncMock()
|
||||
|
||||
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
||||
ca = ContentActions(deps)
|
||||
|
||||
env = await ca.notify(
|
||||
agent_id=agent_id,
|
||||
target="fe-pm",
|
||||
text="Please align frontend cell with new release timeline.",
|
||||
)
|
||||
body = env.as_dict()
|
||||
|
||||
assert body["error"] is None
|
||||
notif_svc.send_ack_notification.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_board_product_owner_succeeds() -> None:
|
||||
"""Product Owner (Board) is allowed."""
|
||||
agent_id = uuid4()
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get_active_task_for_agent.return_value = None
|
||||
task_svc.agent_for.return_value = MagicMock(role="product_owner")
|
||||
notif_svc = AsyncMock()
|
||||
|
||||
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
||||
ca = ContentActions(deps)
|
||||
|
||||
env = await ca.notify(
|
||||
agent_id=agent_id,
|
||||
target="main-pm",
|
||||
text="Roadmap priorities updated; please reflect in Q2 plan.",
|
||||
)
|
||||
body = env.as_dict()
|
||||
|
||||
assert body["error"] is None
|
||||
notif_svc.send_ack_notification.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_board_head_marketing_succeeds() -> None:
|
||||
"""Head of Marketing (Board) is allowed."""
|
||||
agent_id = uuid4()
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get_active_task_for_agent.return_value = None
|
||||
task_svc.agent_for.return_value = MagicMock(role="head_marketing")
|
||||
notif_svc = AsyncMock()
|
||||
|
||||
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
||||
ca = ContentActions(deps)
|
||||
|
||||
env = await ca.notify(
|
||||
agent_id=agent_id,
|
||||
target="main-pm",
|
||||
text="Marketing launch dates confirmed; coordinate engineering deliverables.",
|
||||
)
|
||||
body = env.as_dict()
|
||||
|
||||
assert body["error"] is None
|
||||
notif_svc.send_ack_notification.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_developer_rejected_with_not_authorized() -> None:
|
||||
"""Developer cannot send formal notifications; envelope is not_authorized."""
|
||||
agent_id = uuid4()
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get_active_task_for_agent.return_value = None
|
||||
task_svc.agent_for.return_value = MagicMock(role="developer")
|
||||
notif_svc = AsyncMock()
|
||||
|
||||
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
||||
ca = ContentActions(deps)
|
||||
|
||||
env = await ca.notify(
|
||||
agent_id=agent_id,
|
||||
target="be-pm",
|
||||
text="Heads up — I think the staging deploy is broken.",
|
||||
)
|
||||
body = env.as_dict()
|
||||
|
||||
assert body["error"] == "not_authorized"
|
||||
assert "developer" in body["message"]
|
||||
notif_svc.send_ack_notification.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_qa_rejected_with_not_authorized() -> None:
|
||||
"""QA cannot send formal notifications."""
|
||||
agent_id = uuid4()
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get_active_task_for_agent.return_value = None
|
||||
task_svc.agent_for.return_value = MagicMock(role="qa")
|
||||
notif_svc = AsyncMock()
|
||||
|
||||
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
||||
ca = ContentActions(deps)
|
||||
|
||||
env = await ca.notify(
|
||||
agent_id=agent_id,
|
||||
target="be-pm",
|
||||
text="QA cannot proceed without environment access.",
|
||||
)
|
||||
body = env.as_dict()
|
||||
|
||||
assert body["error"] == "not_authorized"
|
||||
notif_svc.send_ack_notification.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_documenter_rejected_with_not_authorized() -> None:
|
||||
"""Documenter cannot send formal notifications."""
|
||||
agent_id = uuid4()
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get_active_task_for_agent.return_value = None
|
||||
task_svc.agent_for.return_value = MagicMock(role="documenter")
|
||||
notif_svc = AsyncMock()
|
||||
|
||||
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
||||
ca = ContentActions(deps)
|
||||
|
||||
env = await ca.notify(
|
||||
agent_id=agent_id,
|
||||
target="be-pm",
|
||||
text="Documentation review requested.",
|
||||
)
|
||||
body = env.as_dict()
|
||||
|
||||
assert body["error"] == "not_authorized"
|
||||
notif_svc.send_ack_notification.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_auditor_rejected_with_not_authorized() -> None:
|
||||
"""Auditor is read-only — cannot communicate outwardly via notifications."""
|
||||
agent_id = uuid4()
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get_active_task_for_agent.return_value = None
|
||||
task_svc.agent_for.return_value = MagicMock(role="auditor")
|
||||
notif_svc = AsyncMock()
|
||||
|
||||
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
||||
ca = ContentActions(deps)
|
||||
|
||||
env = await ca.notify(
|
||||
agent_id=agent_id,
|
||||
target="ceo",
|
||||
text="Quality concern detected.",
|
||||
)
|
||||
body = env.as_dict()
|
||||
|
||||
assert body["error"] == "not_authorized"
|
||||
notif_svc.send_ack_notification.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_auto_fills_task_id_from_active_task() -> None:
|
||||
"""When the PM has an active task, notify auto-attaches it."""
|
||||
agent_id = uuid4()
|
||||
task_id = uuid4()
|
||||
task_obj = MagicMock(id=task_id, status="awaiting_pm_review")
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get_active_task_for_agent.return_value = task_obj
|
||||
task_svc.agent_for.return_value = MagicMock(role="cell_pm")
|
||||
notif_svc = AsyncMock()
|
||||
|
||||
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
||||
ca = ContentActions(deps)
|
||||
|
||||
env = await ca.notify(
|
||||
agent_id=agent_id,
|
||||
target="be-dev-1",
|
||||
text="Heads up: this task has been escalated for CEO approval.",
|
||||
)
|
||||
body = env.as_dict()
|
||||
|
||||
assert body["error"] is None
|
||||
assert body["task_id"] == str(task_id)
|
||||
call_kwargs = notif_svc.send_ack_notification.call_args.kwargs
|
||||
assert call_kwargs["task_id"] == task_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_unknown_role_rejected() -> None:
|
||||
"""If task.agent_for returns None, treat as unknown role and reject."""
|
||||
agent_id = uuid4()
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get_active_task_for_agent.return_value = None
|
||||
task_svc.agent_for.return_value = None
|
||||
notif_svc = AsyncMock()
|
||||
|
||||
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
||||
ca = ContentActions(deps)
|
||||
|
||||
env = await ca.notify(
|
||||
agent_id=agent_id,
|
||||
target="be-dev-1",
|
||||
text="Test message.",
|
||||
)
|
||||
body = env.as_dict()
|
||||
|
||||
assert body["error"] == "not_authorized"
|
||||
notif_svc.send_ack_notification.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_priority_high_passed_through() -> None:
|
||||
"""Optional priority='high' is forwarded to NotificationService."""
|
||||
agent_id = uuid4()
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get_active_task_for_agent.return_value = None
|
||||
task_svc.agent_for.return_value = MagicMock(role="cell_pm")
|
||||
notif_svc = AsyncMock()
|
||||
|
||||
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
||||
ca = ContentActions(deps)
|
||||
|
||||
env = await ca.notify(
|
||||
agent_id=agent_id,
|
||||
target="be-dev-1",
|
||||
text="Critical: production deployment failed; please join war room.",
|
||||
priority="high",
|
||||
)
|
||||
body = env.as_dict()
|
||||
|
||||
assert body["error"] is None
|
||||
call_kwargs = notif_svc.send_ack_notification.call_args.kwargs
|
||||
assert call_kwargs["priority"] == "high"
|
||||
Reference in New Issue
Block a user