mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
563 lines
19 KiB
Python
563 lines
19 KiB
Python
"""roboco.services.gateway.content_actions.propose_playbook_drafts —
|
|||
|
|
Auditor-gated Librarian playbook-mining authoring. Mirrors
|
||
|
|
test_content_actions_sentinel.py for the validation truth table and the
|
||
|
|
complete-at-propose asymmetry, and test_content_actions_coroner.py for the
|
||
|
|
direct-PlaybookService-draft precedent (never the draft_playbook do-verb)."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from typing import Any
|
||
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from roboco.foundation.policy.content import markers
|
||
|
|
from roboco.models.base import TaskStatus
|
||
|
|
from roboco.services.base import ConflictError
|
||
|
|
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
|
||
|
|
|
||
|
|
TWO = 2
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeTask:
|
||
|
|
"""Minimal stand-in for the ORM TaskTable row — carries just what
|
||
|
|
``propose_playbook_drafts`` touches."""
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
*,
|
||
|
|
assigned_to: Any,
|
||
|
|
orchestration_markers: dict[str, Any] | None = None,
|
||
|
|
status: Any = TaskStatus.PENDING,
|
||
|
|
) -> None:
|
||
|
|
self.id = uuid4()
|
||
|
|
self.assigned_to = assigned_to
|
||
|
|
self.orchestration_markers = orchestration_markers
|
||
|
|
self.status = status
|
||
|
|
|
||
|
|
|
||
|
|
def _actions(role: str, *, notification_delivery: Any = None) -> ContentActions:
|
||
|
|
task = MagicMock()
|
||
|
|
agent = MagicMock()
|
||
|
|
agent.role = role
|
||
|
|
task.agent_for = AsyncMock(return_value=agent)
|
||
|
|
task.session = MagicMock()
|
||
|
|
deps = ContentActionsDeps(
|
||
|
|
task=task,
|
||
|
|
git=MagicMock(),
|
||
|
|
a2a=MagicMock(),
|
||
|
|
journal=MagicMock(),
|
||
|
|
workspace=MagicMock(),
|
||
|
|
notifications=MagicMock(),
|
||
|
|
notification_delivery=notification_delivery,
|
||
|
|
)
|
||
|
|
return ContentActions(deps)
|
||
|
|
|
||
|
|
|
||
|
|
def _valid_draft(idx: int) -> dict[str, Any]:
|
||
|
|
return {
|
||
|
|
"title": f"Playbook title number {idx}",
|
||
|
|
"body": f"A substantive procedure body for draft {idx} " * 3,
|
||
|
|
"pattern_evidence": f"Recurred across at least two journal entries {idx}",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _valid_drafts(n: int) -> list[dict[str, Any]]:
|
||
|
|
return [_valid_draft(i) for i in range(n)]
|
||
|
|
|
||
|
|
|
||
|
|
def _no_existing_titles() -> Any:
|
||
|
|
"""Patch target for LibrarianEngine.existing_playbook_titles_lower —
|
||
|
|
empty by default so the field-validation truth table below isolates each
|
||
|
|
check without also needing a live DB dedup pass."""
|
||
|
|
engine = MagicMock()
|
||
|
|
engine.existing_playbook_titles_lower = AsyncMock(return_value=set())
|
||
|
|
return patch(
|
||
|
|
"roboco.services.librarian_engine.get_librarian_engine",
|
||
|
|
return_value=engine,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Role gate
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_forbidden_for_product_owner() -> None:
|
||
|
|
env = await _actions("product_owner").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=_valid_drafts(1)
|
||
|
|
)
|
||
|
|
assert env.error == "not_authorized"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_forbidden_for_head_marketing() -> None:
|
||
|
|
env = await _actions("head_marketing").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=_valid_drafts(1)
|
||
|
|
)
|
||
|
|
assert env.error == "not_authorized"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_forbidden_for_developer() -> None:
|
||
|
|
env = await _actions("developer").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=_valid_drafts(1)
|
||
|
|
)
|
||
|
|
assert env.error == "not_authorized"
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Count bounds (1-3)
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_rejects_empty_drafts() -> None:
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(agent_id=uuid4(), drafts=[])
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_rejects_too_many_drafts() -> None:
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=_valid_drafts(4)
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_accepts_three_drafts_shape(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[])
|
||
|
|
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=_valid_drafts(3)
|
||
|
|
)
|
||
|
|
# No open cycle exists — proves the count itself was accepted (3 is the
|
||
|
|
# upper bound, not rejected by the count check).
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
assert "no open Librarian mining task" in (env.message or "")
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Per-draft field validation
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_rejects_non_dict_draft() -> None:
|
||
|
|
bad_drafts: list[Any] = ["not a dict"]
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=bad_drafts
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_rejects_draft_missing_title() -> None:
|
||
|
|
bad = _valid_draft(0)
|
||
|
|
del bad["title"]
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=[bad]
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
assert "title" in (env.message or "")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_rejects_draft_missing_body() -> None:
|
||
|
|
bad = _valid_draft(0)
|
||
|
|
del bad["body"]
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=[bad]
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
assert "body" in (env.message or "")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_rejects_draft_missing_pattern_evidence() -> None:
|
||
|
|
bad = _valid_draft(0)
|
||
|
|
del bad["pattern_evidence"]
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=[bad]
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
assert "pattern_evidence" in (env.message or "")
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_rejects_soup_title() -> None:
|
||
|
|
bad = _valid_draft(0)
|
||
|
|
bad["title"] = "asdf"
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=[bad]
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_rejects_oversized_title() -> None:
|
||
|
|
bad = _valid_draft(0)
|
||
|
|
bad["title"] = "x" * 201
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=[bad]
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_rejects_oversized_body() -> None:
|
||
|
|
bad = _valid_draft(0)
|
||
|
|
bad["body"] = "x" * 4001
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=[bad]
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_rejects_oversized_pattern_evidence() -> None:
|
||
|
|
bad = _valid_draft(0)
|
||
|
|
bad["pattern_evidence"] = "x" * 501
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=[bad]
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_rejects_thin_pattern_evidence() -> None:
|
||
|
|
bad = _valid_draft(0)
|
||
|
|
bad["pattern_evidence"] = "too short"
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=[bad]
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# In-batch duplicate-title dedup
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_rejects_duplicate_titles_in_batch() -> None:
|
||
|
|
first = _valid_draft(0)
|
||
|
|
second = _valid_draft(1)
|
||
|
|
second["title"] = first["title"].upper() # case-insensitive collision
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=[first, second]
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
assert "duplicates another draft in this batch" in (env.message or "")
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Open-cycle lookup
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_no_open_cycle_is_invalid_state(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[])
|
||
|
|
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=_valid_drafts(1)
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_ignores_cycle_assigned_to_another_agent(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
other_agent = uuid4()
|
||
|
|
cycle_task = _FakeTask(assigned_to=other_agent)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
|
||
|
|
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=uuid4(), drafts=_valid_drafts(1)
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_ignores_already_authored_cycle(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
agent_id = uuid4()
|
||
|
|
authored = _FakeTask(
|
||
|
|
assigned_to=agent_id,
|
||
|
|
orchestration_markers={"playbook_drafts": {"drafts": []}},
|
||
|
|
)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[authored])
|
||
|
|
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=agent_id, drafts=_valid_drafts(1)
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Existing-title dedup (live DB check, past the field-validation stage)
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_rejects_title_duplicating_existing_playbook(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
agent_id = uuid4()
|
||
|
|
draft = _valid_draft(0)
|
||
|
|
cycle_task = _FakeTask(assigned_to=agent_id)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
|
||
|
|
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||
|
|
|
||
|
|
engine = MagicMock()
|
||
|
|
engine.existing_playbook_titles_lower = AsyncMock(
|
||
|
|
return_value={draft["title"].lower()}
|
||
|
|
)
|
||
|
|
with patch(
|
||
|
|
"roboco.services.librarian_engine.get_librarian_engine",
|
||
|
|
return_value=engine,
|
||
|
|
):
|
||
|
|
env = await _actions("auditor").propose_playbook_drafts(
|
||
|
|
agent_id=agent_id, drafts=[draft]
|
||
|
|
)
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
assert "duplicates an existing playbook" in (env.message or "")
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# Happy path — direct PlaybookService.draft(), never draft_playbook
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_creates_playbooks_and_completes_task(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
agent_id = uuid4()
|
||
|
|
cycle_task = _FakeTask(assigned_to=agent_id)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
|
||
|
|
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||
|
|
actions = _actions("auditor")
|
||
|
|
actions.task.session.flush = AsyncMock()
|
||
|
|
|
||
|
|
drafted_1, drafted_2 = MagicMock(), MagicMock()
|
||
|
|
drafted_1.id, drafted_1.title = uuid4(), "Playbook title number 0"
|
||
|
|
drafted_2.id, drafted_2.title = uuid4(), "Playbook title number 1"
|
||
|
|
playbook_svc = MagicMock()
|
||
|
|
playbook_svc.draft = AsyncMock(side_effect=[drafted_1, drafted_2])
|
||
|
|
|
||
|
|
with (
|
||
|
|
_no_existing_titles(),
|
||
|
|
patch(
|
||
|
|
"roboco.services.playbook.get_playbook_service",
|
||
|
|
return_value=playbook_svc,
|
||
|
|
),
|
||
|
|
):
|
||
|
|
env = await actions.propose_playbook_drafts(
|
||
|
|
agent_id=agent_id, drafts=_valid_drafts(2)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert env.error is None
|
||
|
|
assert env.status == "playbook_drafts_proposed"
|
||
|
|
assert env.task_id == str(cycle_task.id)
|
||
|
|
assert playbook_svc.draft.await_count == TWO
|
||
|
|
|
||
|
|
payload = markers.get_playbook_drafts(cycle_task)
|
||
|
|
assert payload is not None
|
||
|
|
assert len(payload["drafts"]) == TWO
|
||
|
|
assert payload["drafts"][0]["title"] == "Playbook title number 0"
|
||
|
|
|
||
|
|
# Complete-at-propose: the mining task completes in THIS call — mirrors
|
||
|
|
# the x_feature/periscope/sentinel asymmetry.
|
||
|
|
assert cycle_task.status == TaskStatus.COMPLETED
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_calls_playbook_service_draft_directly(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""The invariant this whole verb exists to preserve: each playbook is
|
||
|
|
created directly via PlaybookService.draft() — the do-verb never routes
|
||
|
|
through the draft_playbook content-tool path (which the Auditor doesn't
|
||
|
|
carry on its manifest, see test_playbook_verbs.py's invariant)."""
|
||
|
|
agent_id = uuid4()
|
||
|
|
cycle_task = _FakeTask(assigned_to=agent_id)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
|
||
|
|
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||
|
|
actions = _actions("auditor")
|
||
|
|
actions.task.session.flush = AsyncMock()
|
||
|
|
|
||
|
|
drafted = MagicMock()
|
||
|
|
drafted.id, drafted.title = uuid4(), "Playbook title number 0"
|
||
|
|
playbook_svc = MagicMock()
|
||
|
|
playbook_svc.draft = AsyncMock(return_value=drafted)
|
||
|
|
|
||
|
|
with (
|
||
|
|
_no_existing_titles(),
|
||
|
|
patch(
|
||
|
|
"roboco.services.playbook.get_playbook_service",
|
||
|
|
return_value=playbook_svc,
|
||
|
|
) as get_playbook_svc,
|
||
|
|
):
|
||
|
|
env = await actions.propose_playbook_drafts(
|
||
|
|
agent_id=agent_id, drafts=_valid_drafts(1)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert env.error is None
|
||
|
|
get_playbook_svc.assert_called_once()
|
||
|
|
playbook_svc.draft.assert_awaited_once()
|
||
|
|
create_call = playbook_svc.draft.await_args
|
||
|
|
assert create_call.kwargs["created_by"] == agent_id
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_conflict_error_is_clean_rejection(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""A same-tick race past the live pre-check (ConflictError from
|
||
|
|
PlaybookService.draft itself) aborts with a clean rejection — mirrors
|
||
|
|
test_propose_postmortem_playbook_title_conflict_is_clean_rejection."""
|
||
|
|
agent_id = uuid4()
|
||
|
|
cycle_task = _FakeTask(assigned_to=agent_id)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
|
||
|
|
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||
|
|
actions = _actions("auditor")
|
||
|
|
actions.task.session.flush = AsyncMock()
|
||
|
|
|
||
|
|
playbook_svc = MagicMock()
|
||
|
|
playbook_svc.draft = AsyncMock(
|
||
|
|
side_effect=ConflictError("dup slug", resource_type="playbook")
|
||
|
|
)
|
||
|
|
|
||
|
|
with (
|
||
|
|
_no_existing_titles(),
|
||
|
|
patch(
|
||
|
|
"roboco.services.playbook.get_playbook_service",
|
||
|
|
return_value=playbook_svc,
|
||
|
|
),
|
||
|
|
):
|
||
|
|
env = await actions.propose_playbook_drafts(
|
||
|
|
agent_id=agent_id, drafts=_valid_drafts(1)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert env.error == "invalid_state"
|
||
|
|
assert cycle_task.status != TaskStatus.COMPLETED
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_sends_telegram_push_once(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
agent_id = uuid4()
|
||
|
|
cycle_task = _FakeTask(assigned_to=agent_id)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
|
||
|
|
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||
|
|
notify = AsyncMock()
|
||
|
|
actions = _actions("auditor", notification_delivery=notify)
|
||
|
|
actions.task.session.flush = AsyncMock()
|
||
|
|
|
||
|
|
drafted = MagicMock()
|
||
|
|
drafted.id, drafted.title = uuid4(), "Playbook title number 0"
|
||
|
|
playbook_svc = MagicMock()
|
||
|
|
playbook_svc.draft = AsyncMock(return_value=drafted)
|
||
|
|
|
||
|
|
with (
|
||
|
|
_no_existing_titles(),
|
||
|
|
patch(
|
||
|
|
"roboco.services.playbook.get_playbook_service",
|
||
|
|
return_value=playbook_svc,
|
||
|
|
),
|
||
|
|
):
|
||
|
|
env = await actions.propose_playbook_drafts(
|
||
|
|
agent_id=agent_id, drafts=_valid_drafts(1)
|
||
|
|
)
|
||
|
|
assert env.error is None
|
||
|
|
|
||
|
|
notify.notify_ceo_of_librarian_drafts.assert_awaited_once()
|
||
|
|
call = notify.notify_ceo_of_librarian_drafts.await_args
|
||
|
|
assert call.kwargs["task"] is cycle_task
|
||
|
|
assert call.kwargs["task_id"] == cycle_task.id
|
||
|
|
assert call.kwargs["titles"] == ["Playbook title number 0"]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_survives_telegram_push_failure(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
agent_id = uuid4()
|
||
|
|
cycle_task = _FakeTask(assigned_to=agent_id)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
|
||
|
|
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||
|
|
notify = MagicMock()
|
||
|
|
notify.notify_ceo_of_librarian_drafts = AsyncMock(side_effect=RuntimeError("boom"))
|
||
|
|
actions = _actions("auditor", notification_delivery=notify)
|
||
|
|
actions.task.session.flush = AsyncMock()
|
||
|
|
|
||
|
|
drafted = MagicMock()
|
||
|
|
drafted.id, drafted.title = uuid4(), "Playbook title number 0"
|
||
|
|
playbook_svc = MagicMock()
|
||
|
|
playbook_svc.draft = AsyncMock(return_value=drafted)
|
||
|
|
|
||
|
|
with (
|
||
|
|
_no_existing_titles(),
|
||
|
|
patch(
|
||
|
|
"roboco.services.playbook.get_playbook_service",
|
||
|
|
return_value=playbook_svc,
|
||
|
|
),
|
||
|
|
):
|
||
|
|
env = await actions.propose_playbook_drafts(
|
||
|
|
agent_id=agent_id, drafts=_valid_drafts(1)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert env.error is None
|
||
|
|
assert env.status == "playbook_drafts_proposed"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_propose_playbook_drafts_no_notification_delivery_is_a_no_op(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
agent_id = uuid4()
|
||
|
|
cycle_task = _FakeTask(assigned_to=agent_id)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_librarian_cycles = AsyncMock(return_value=[cycle_task])
|
||
|
|
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||
|
|
actions = _actions("auditor", notification_delivery=None)
|
||
|
|
actions.task.session.flush = AsyncMock()
|
||
|
|
|
||
|
|
drafted = MagicMock()
|
||
|
|
drafted.id, drafted.title = uuid4(), "Playbook title number 0"
|
||
|
|
playbook_svc = MagicMock()
|
||
|
|
playbook_svc.draft = AsyncMock(return_value=drafted)
|
||
|
|
|
||
|
|
with (
|
||
|
|
_no_existing_titles(),
|
||
|
|
patch(
|
||
|
|
"roboco.services.playbook.get_playbook_service",
|
||
|
|
return_value=playbook_svc,
|
||
|
|
),
|
||
|
|
):
|
||
|
|
env = await actions.propose_playbook_drafts(
|
||
|
|
agent_id=agent_id, drafts=_valid_drafts(1)
|
||
|
|
)
|
||
|
|
assert env.error is None
|