Files
roboco/tests/unit/gateway/test_content_actions_war_room.py
T

269 lines
8.6 KiB
Python
Raw Normal View History

"""roboco.services.gateway.content_actions.propose_campaign — Head-of-
Marketing-gated War Room campaign authoring. Mirrors
test_content_actions_coroner.py."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.models.base import TaskStatus
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
THREE = 3
class _FakeTask:
"""Minimal stand-in for the ORM TaskTable row — carries just what
``propose_campaign`` touches."""
def __init__(self, *, assigned_to: Any) -> None:
self.id = uuid4()
self.assigned_to = assigned_to
self.orchestration_markers: dict[str, Any] | None = None
self.status = TaskStatus.PENDING
def _actions(role: str) -> 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=None,
)
return ContentActions(deps)
def _future(hours: float) -> str:
return (datetime.now(UTC) + timedelta(hours=hours)).isoformat()
def _post(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"body": "RoboCo v0.30.0 teaser drops soon.",
"publish_after": _future(1),
"stage_label": "teaser",
}
base.update(overrides)
return base
def _valid_kwargs(**overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = {
"agent_id": uuid4(),
"campaign_name": "v0.30.0 launch",
"posts": [
_post(stage_label="teaser", publish_after=_future(1)),
_post(stage_label="launch", publish_after=_future(2)),
],
}
base.update(overrides)
return base
@pytest.mark.asyncio
async def test_propose_campaign_forbidden_for_non_hom() -> None:
env = await _actions("product_owner").propose_campaign(**_valid_kwargs())
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_propose_campaign_forbidden_for_auditor() -> None:
env = await _actions("auditor").propose_campaign(**_valid_kwargs())
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_propose_campaign_rejects_short_name() -> None:
env = await _actions("head_marketing").propose_campaign(
**_valid_kwargs(campaign_name="ab")
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_campaign_rejects_oversized_name() -> None:
env = await _actions("head_marketing").propose_campaign(
**_valid_kwargs(campaign_name="x" * 101)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_campaign_rejects_single_post() -> None:
env = await _actions("head_marketing").propose_campaign(
**_valid_kwargs(posts=[_post()])
)
assert env.error == "invalid_state"
assert "2-6" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_campaign_rejects_seven_posts() -> None:
posts = [_post(publish_after=_future(i + 1)) for i in range(7)]
env = await _actions("head_marketing").propose_campaign(
**_valid_kwargs(posts=posts)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_campaign_accepts_six_posts_shape() -> None:
"""Six is the top of the valid range — this must clear the count gate
(the "no open cycle" rejection proves the count check itself passed)."""
posts = [_post(publish_after=_future(i + 1)) for i in range(6)]
actions = _actions("head_marketing")
with patch("roboco.services.task.get_task_service") as get_task_service:
task_svc = MagicMock()
task_svc.list_open_war_room_cycles = AsyncMock(return_value=[])
get_task_service.return_value = task_svc
env = await actions.propose_campaign(**_valid_kwargs(posts=posts))
assert env.error == "invalid_state"
assert "no open war-room exploration task" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_campaign_rejects_oversized_body() -> None:
env = await _actions("head_marketing").propose_campaign(
**_valid_kwargs(posts=[_post(body="x" * 281), _post(publish_after=_future(2))])
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_campaign_rejects_unknown_stage_label() -> None:
env = await _actions("head_marketing").propose_campaign(
**_valid_kwargs(
posts=[
_post(stage_label="not_a_stage"),
_post(publish_after=_future(2)),
]
)
)
assert env.error == "invalid_state"
assert "stage_label" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_campaign_rejects_past_publish_after() -> None:
env = await _actions("head_marketing").propose_campaign(
**_valid_kwargs(
posts=[
_post(publish_after=_future(-1)),
_post(publish_after=_future(2)),
]
)
)
assert env.error == "invalid_state"
assert "future" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_campaign_rejects_non_ascending_publish_after() -> None:
env = await _actions("head_marketing").propose_campaign(
**_valid_kwargs(
posts=[
_post(publish_after=_future(3)),
_post(publish_after=_future(1)),
]
)
)
assert env.error == "invalid_state"
assert "ascending" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_campaign_rejects_malformed_publish_after() -> None:
env = await _actions("head_marketing").propose_campaign(
**_valid_kwargs(
posts=[
_post(publish_after="not-a-date"),
_post(publish_after=_future(2)),
]
)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_campaign_no_open_cycle_assigned() -> None:
actions = _actions("head_marketing")
with patch("roboco.services.task.get_task_service") as get_task_service:
task_svc = MagicMock()
task_svc.list_open_war_room_cycles = AsyncMock(return_value=[])
get_task_service.return_value = task_svc
env = await actions.propose_campaign(**_valid_kwargs())
assert env.error == "invalid_state"
assert "no open war-room exploration task" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_campaign_materializes_every_post_in_order_and_completes() -> (
None
):
agent_id = uuid4()
task = _FakeTask(assigned_to=agent_id)
actions = _actions("head_marketing")
actions.task.session.flush = AsyncMock()
materialized: list[dict[str, Any]] = []
async def _materialize(
*, exploration_task: Any, campaign_ref: dict[str, Any], body: str
) -> Any:
assert exploration_task is task
drafted = MagicMock()
drafted.id = uuid4()
materialized.append({"campaign_ref": campaign_ref, "body": body})
return drafted
with (
patch("roboco.services.task.get_task_service") as get_task_service,
patch("roboco.services.x_engine.get_x_engine") as get_x_engine,
):
task_svc = MagicMock()
task_svc.list_open_war_room_cycles = AsyncMock(return_value=[task])
get_task_service.return_value = task_svc
engine = MagicMock()
engine.materialize_campaign_post = AsyncMock(side_effect=_materialize)
get_x_engine.return_value = engine
env = await actions.propose_campaign(
**_valid_kwargs(
agent_id=agent_id,
posts=[
_post(stage_label="teaser", publish_after=_future(1)),
_post(stage_label="launch", publish_after=_future(2)),
_post(stage_label="follow_up", publish_after=_future(3)),
],
)
)
assert env.status == "campaign_proposed"
assert env.context_briefing == {
"campaign_name": "v0.30.0 launch",
"post_count": THREE,
}
assert len(materialized) == THREE
assert [m["campaign_ref"]["sequence"] for m in materialized] == [1, 2, THREE]
assert [m["campaign_ref"]["stage_label"] for m in materialized] == [
"teaser",
"launch",
"follow_up",
]
assert all(
m["campaign_ref"]["campaign_name"] == "v0.30.0 launch" for m in materialized
)
assert task.status == TaskStatus.COMPLETED