mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [0d515123] fix(notification): DB purpose-dedup on _persist_and_deliver task-handoff path (#719)
Extract the shared dedup query (same sender+type+task, exact recipient-set
equality, prior still unacked) into notification_dedup.duplicate_unacked_notification_exists
and call it from both NotificationService._duplicate_unacked_exists and
NotificationDeliveryService._persist_and_deliver, closing the gap where a
retried i_am_blocked/escalate past the 60s Redis window re-created a second
unacked notification. Adds an integration test proving the suppression, and
corrects docs/map/notification.md's stale claim about the ACK path (it was
already using the transactional outbox before this task).
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
* [ec7e3986] fix(notification): SQL COUNT aggregates for get_notification_count instead of in-memory scan (#728)
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
* [532b7162] Fix over-broad dedup: exempt re-escalation ladder + rework ALERTs; fix tests/docs (#744)
* [532b7162] fix(notifications): exempt re-escalation ladder + rework ALERTs from DB dedup
The DB purpose-dedup added inside NotificationDeliveryService._persist_and_deliver
was applied unconditionally, silently suppressing two paths that intentionally
re-send an identical (sender, type, task) signal: the blocker re-escalation
ladder (_re_escalate_recipient) and rework ALERTs (notify_auditor_of_rework).
Both now pass a caller-scoped bypass_purpose_dedup=True flag instead of
exempting by notification type, which would have reopened the original
retried-first-send-blocker gap. Also stamps expires_at on re-escalation rows
so the ladder can keep expiring/escalating, corrects the now-inaccurate
_maybe_reescalate docstring, fixes the sweeper test's dedup-SELECT mock blind
spot, and adds a real-db_session integration test proving two sequential
re-escalations both deliver.
* [532b7162] docs(notification): correct dedup-suppression claims for re-escalation + rework ALERT exemption
---------
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
* [4593a7d3] Fix PR-gate findings: scope DB dedup off re-escalation ladder + rework ALERTs (#780)
* [7117996c] docs(changelog): document the re-escalation ladder and rework ALERT dedup exemption fix (#777)
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
* [e06dc4d9] Fix CI-red quality gate + tautological test assertion on PR #780 (#798)
* [e06dc4d9] fix: regenerate stale lifecycle artifacts + replace tautological assertion
The CI 'Python quality gate' (make quality) failed on PR #780's head
because foundation-check detected lifecycle-artifact drift: the committed
panel/lib/lifecycle.json and docs/rag/lifecycle/status-transitions.md
still carried an `awaiting_pm_review -> claimed` claim transition that the
current lifecycle spec no longer emits. make gate (format/lint/mypy/xenon)
does not run foundation-check, so the reviewer could not reproduce locally.
Fix: ran `make lifecycle` to regenerate the artifacts from the spec and
commit the diff — no hand-written spec change, just stale generated output
brought current.
Second finding: tests/integration/test_notification_reescalation_dedup_exemption.py:164
had a tautological `assert target.id is not None` (target.id was uuid4() at
construction, so it could never fail). Replaced with a real DB query that
fetches all BLOCKER_ESCALATION rows for the sender/task, filters to those
addressed to target.id, and asserts exactly 2 re-escalation rows exist —
proving both _re_escalate_recipient calls delivered to the right recipient.
Verified: make gate green (format/lint/mypy/xenon); quality-fast suite
shows 7779 passed (same count as pre-change), the only failure is the
pre-existing local-only cloud_auth test (needs localhost:5432 which CI
provides); no regression in the notification dedup tests.
* [e06dc4d9] revert: restore lifecycle artifacts that prior round removed via wrong spec
The prior commit (ca84df26) regenerated panel/lib/lifecycle.json and
docs/rag/lifecycle/status-transitions.md using the MAIN checkout's
editable install (.pth → /data/workspaces/roboco-api/backend/be-dev-1)
which lacks the AWAITING_PM_REVIEW→CLAIMED transition, instead of the
worktree branch spec (lifecycle.py:250-255) which has it. This removed
the transition from both committed artifacts, introducing NEW drift that
CI's foundation-check would catch (regenerate WITH the transition from
the branch spec → git diff vs committed → FAIL).
Fix: restore the awaiting_pm_review→claimed claim transition row in
status-transitions.md and the claim_rules + transition entries in
lifecycle.json, reverting to the pre-ca84df26 state that already matched
the branch spec. The tautological assertion fix from the prior commit is
kept unchanged (QA confirmed correct).
Verified against the CORRECT (worktree) spec via PYTHONPATH override:
- make gate green (format/lint/mypy/xenon)
- pytest 15347 passed, coverage 93.83% (>80%)
- prose/vulture/deptry/imports/bandit/radon/alembic/pip-audit all pass
- foundation-check passes once committed (artifacts match branch spec)
- only failure is the local-only cloud_auth test (needs localhost:5432,
which CI provides)
* [e06dc4d9] docs(notification): strengthen re-escalation dedup test description to reflect real assertion
---------
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
---------
Co-authored-by: roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com>
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
---------
Co-authored-by: roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com>
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
352 lines
12 KiB
Python
352 lines
12 KiB
Python
"""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_not_suppressed_by_existing_duplicate(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A second `needs_revision` on the same task from the same actor must
|
|
still reach the auditor even while the first ALERT is unacked (PR #742's
|
|
unconditional DB dedup silently suppressed this). `notify_auditor_of_rework`
|
|
passes `bypass_purpose_dedup=True`, so the dedup check is never even
|
|
consulted for this call path."""
|
|
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))
|
|
dedup_spy = AsyncMock(return_value=True) # would suppress if ever consulted
|
|
|
|
task = _mock_task()
|
|
with (
|
|
patch(
|
|
"roboco.services.notification_delivery.all_recipients_recently_notified",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch(
|
|
"roboco.services.notification_delivery.duplicate_unacked_notification_exists",
|
|
dedup_spy,
|
|
),
|
|
):
|
|
await svc.notify_auditor_of_rework(
|
|
task=task,
|
|
task_id=require_uuid(task.id),
|
|
reason="second rework on the same task",
|
|
actor_agent_id=actor.id,
|
|
actor_role="qa",
|
|
)
|
|
|
|
dedup_spy.assert_not_called()
|
|
added = [c for c in session.add.call_args_list if c.args]
|
|
assert len(added) == 1, "rework ALERT must deliver despite an unacked duplicate"
|
|
|
|
|
|
@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()
|
|
# fail_qa releases the QA agent's fleet marker via session.get — default
|
|
# to "no matching row" for a test that doesn't care about that side effect.
|
|
session.get = AsyncMock(return_value=None)
|
|
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()
|
|
# pr_fail releases the reviewer's fleet marker via session.get — default
|
|
# to "no matching row" for a test that doesn't care about that side effect.
|
|
session.get = AsyncMock(return_value=None)
|
|
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
|