Files
roboco/tests/unit/services/test_auditor_alert_producers.py
T

304 lines
10 KiB
Python
Raw Normal View History

"""Unit tests for auditor-targeted rework alert producers.
Covers the reactive audit dispatch path: ``NotificationDeliveryService``
creates ``ALERT`` notifications addressed to the auditor, and ``TaskService``
invokes that producer at the QA-fail / rework chokepoints
(``fail_qa``, ``pr_fail``, ``request_changes``).
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import UUID, uuid4
import pytest
from roboco.models.base import (
NotificationPriority,
NotificationType,
TaskStatus,
)
from roboco.services.notification_delivery import NotificationDeliveryService
from roboco.services.task import TaskService
from roboco.utils.converters import require_uuid
def _mock_task(
task_id: UUID | None = None, status: Any = TaskStatus.AWAITING_QA
) -> MagicMock:
task = MagicMock()
task.id = task_id or uuid4()
task.title = "Test task"
task.status = status
task.assigned_to = uuid4()
task.claimed_by = None
task.active_claimant_id = None
task.team = MagicMock()
task.team.value = "backend"
task.qa_verified = True
task.qa_notes = None
task.dev_notes = None
task.orchestration_markers = {}
task.notes_structured = None
task.revision_count = 0
return task
def _mock_agent(*, role: str = "auditor", slug: str = "auditor") -> MagicMock:
agent = MagicMock()
agent.id = uuid4()
agent.role = role
agent.slug = slug
return agent
def _session_with_agent(agent: MagicMock | None) -> MagicMock:
"""A session whose execute().scalars().first() returns ``agent``.
``session.add`` assigns a fresh UUID to the notification so that
``_persist_and_deliver`` can call ``require_uuid(notification.id)``.
"""
session = MagicMock()
def _assign_id(obj: Any) -> None:
if getattr(obj, "id", None) is None:
obj.id = uuid4()
session.add = MagicMock(side_effect=_assign_id)
session.flush = AsyncMock()
result = MagicMock()
result.scalars.return_value.first.return_value = agent
result.scalar_one_or_none.return_value = agent
session.execute = AsyncMock(return_value=result)
return session
# ---------------------------------------------------------------------------
# NotificationDeliveryService.notify_auditor_of_rework
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_notify_auditor_of_rework_creates_alert_to_auditor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The delivery service builds an ALERT notification targeted at the auditor."""
auditor = _mock_agent(role="auditor", slug="auditor")
actor = _mock_agent(role="qa", slug="be-qa")
session = _session_with_agent(auditor)
svc = NotificationDeliveryService(session)
monkeypatch.setattr(svc, "_get_auditor_agent", AsyncMock(return_value=auditor))
monkeypatch.setattr(svc, "_get_agent_by_id", AsyncMock(return_value=actor))
monkeypatch.setattr(svc, "deliver", AsyncMock(return_value=True))
task = _mock_task()
with patch(
"roboco.services.notification_delivery.all_recipients_recently_notified",
AsyncMock(return_value=False),
):
await svc.notify_auditor_of_rework(
task=task,
task_id=require_uuid(task.id),
reason="QA review failed",
actor_agent_id=actor.id,
actor_role="qa",
)
added = [c for c in session.add.call_args_list if c.args]
assert len(added) == 1
notification = added[0].args[0]
assert notification.type == NotificationType.ALERT
assert notification.priority == NotificationPriority.HIGH
assert notification.to_agents == [auditor.id]
assert notification.from_agent == actor.id
assert "QA review failed" in notification.body
assert "Actor role: qa" in notification.body
assert notification.requires_ack is True
@pytest.mark.asyncio
async def test_notify_auditor_of_rework_skips_when_no_auditor(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""If the auditor agent is absent, the producer is a silent no-op."""
session = _session_with_agent(None)
svc = NotificationDeliveryService(session)
monkeypatch.setattr(svc, "_get_auditor_agent", AsyncMock(return_value=None))
deliver_spy = AsyncMock()
monkeypatch.setattr(svc, "deliver", deliver_spy)
task = _mock_task()
await svc.notify_auditor_of_rework(
task=task,
task_id=require_uuid(task.id),
reason="QA review failed",
)
assert not session.add.called
assert not deliver_spy.called
# ---------------------------------------------------------------------------
# TaskService._alert_auditor_of_rework
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_alert_auditor_of_rework_calls_delivery_service() -> None:
"""TaskService delegates to the notification delivery service."""
session = MagicMock()
session.flush = AsyncMock()
task = _mock_task()
svc = TaskService(session)
fake_delivery = AsyncMock()
fake_delivery.notify_auditor_of_rework = AsyncMock()
with patch(
"roboco.services.notification_delivery.get_notification_delivery_service",
lambda _s: fake_delivery,
):
await svc._alert_auditor_of_rework(
task,
reason="QA review failed",
actor_agent_id=task.assigned_to,
actor_role="qa",
)
fake_delivery.notify_auditor_of_rework.assert_awaited_once()
call = fake_delivery.notify_auditor_of_rework.await_args
assert call.kwargs["task"] is task
assert call.kwargs["reason"] == "QA review failed"
assert call.kwargs["actor_role"] == "qa"
assert call.kwargs["actor_agent_id"] == task.assigned_to
@pytest.mark.asyncio
async def test_alert_auditor_of_rework_is_best_effort() -> None:
"""A delivery failure must not raise out of the producer."""
session = MagicMock()
task = _mock_task()
svc = TaskService(session)
with patch(
"roboco.services.notification_delivery.get_notification_delivery_service",
side_effect=RuntimeError("redis down"),
):
await svc._alert_auditor_of_rework(
task, reason="QA review failed", actor_role="qa"
)
# ---------------------------------------------------------------------------
# Chokepoint wiring: fail_qa, pr_fail, request_changes
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fail_qa_emits_auditor_alert(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``fail_qa`` calls the auditor rework producer with QA attribution."""
session = MagicMock()
session.flush = AsyncMock()
task = _mock_task(status=TaskStatus.AWAITING_QA)
task.orchestration_markers = {"original_developer": str(uuid4())}
svc = TaskService(session)
monkeypatch.setattr(svc, "get", AsyncMock(return_value=task))
monkeypatch.setattr(svc, "_validate_and_set_status", MagicMock())
alert_spy = AsyncMock()
monkeypatch.setattr(svc, "_alert_auditor_of_rework", alert_spy)
with (
patch(
"roboco.services.task.extract_original_developer",
return_value=str(uuid4()),
),
patch("roboco.services.task.asyncio.create_task", MagicMock()),
):
out = await svc.fail_qa(task.id, notes="missing tests")
assert out is task
alert_spy.assert_awaited_once()
call = alert_spy.await_args
assert call is not None
assert call.args[0] is task
assert call.kwargs["reason"] == "missing tests"
assert call.kwargs["actor_role"] == "qa"
@pytest.mark.asyncio
async def test_pr_fail_emits_auditor_alert(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``pr_fail`` calls the auditor rework producer with reviewer attribution."""
session = MagicMock()
session.flush = AsyncMock()
reviewer_id = uuid4()
pm_id = uuid4()
task = _mock_task(status=TaskStatus.AWAITING_PR_REVIEW)
task.claimed_by = reviewer_id
pm = MagicMock()
pm.id = pm_id
svc = TaskService(session)
monkeypatch.setattr(svc, "get", AsyncMock(return_value=task))
monkeypatch.setattr(svc, "_validate_and_set_status", MagicMock())
monkeypatch.setattr(svc, "_revision_pm_for_task", AsyncMock(return_value=pm))
alert_spy = AsyncMock()
monkeypatch.setattr(svc, "_alert_auditor_of_rework", alert_spy)
out = await svc.pr_fail(
reviewer_id, task.id, notes="convention violation", issues=["mv model"]
)
assert out is task
alert_spy.assert_awaited_once()
call = alert_spy.await_args
assert call is not None
assert call.args[0] is task
assert call.kwargs["reason"] == "convention violation"
assert call.kwargs["actor_role"] == "pr_reviewer"
assert call.kwargs["actor_agent_id"] == reviewer_id
@pytest.mark.asyncio
async def test_request_changes_emits_auditor_alert(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``request_changes`` calls the auditor rework producer with PM attribution."""
session = MagicMock()
session.flush = AsyncMock()
pm_id = uuid4()
pm = MagicMock()
pm.id = pm_id
task = _mock_task(status=TaskStatus.AWAITING_PM_REVIEW)
task.claimed_by = pm_id
svc = TaskService(session)
monkeypatch.setattr(svc, "get", AsyncMock(return_value=task))
monkeypatch.setattr(svc, "_validate_and_set_status", MagicMock())
monkeypatch.setattr(svc, "_revision_pm_for_task", AsyncMock(return_value=pm))
alert_spy = AsyncMock()
monkeypatch.setattr(svc, "_alert_auditor_of_rework", alert_spy)
with patch("roboco.services.task.extract_original_developer", return_value=None):
out = await svc.request_changes(
pm_id,
task.id,
notes="AC missing",
issues=["add test"],
agent_role="cell_pm",
)
assert out is task
alert_spy.assert_awaited_once()
call = alert_spy.await_args
assert call is not None
assert call.args[0] is task
assert call.kwargs["reason"] == "AC missing"
assert call.kwargs["actor_role"] == "cell_pm"
assert call.kwargs["actor_agent_id"] == pm_id