Files

360 lines
14 KiB
Python
Raw Permalink Normal View History

"""Scenario: the video-generation pipeline, render loop through CEO approval.
Regression coverage for the video engine's cross-layer wiring (Phase H): a
completed ``source=video`` authoring task is rendered by the orchestrator's
render loop into a held ``source=video_post`` draft, which the CEO approves
through ``VideoPostService``. Exercises the REAL dispatcher skip-predicates
(``video_post`` must never reach a dev/PM dispatcher — the authoring source
itself is the contrast case, since it dispatches normally), the REAL
``propose_video`` do-tool via the REAL do_server registry (mirrors
``test_feature_spotlight.py``'s guard against a verb wired at
role_config/content_actions but dropped from ``do_server._TOOLS``), the REAL
render -> materialize chain (only the video-renderer sidecar client + the
workspace read-clone are mocked — the external-I/O boundary), and the REAL
``VideoPostService.approve`` (only the X-v2 + TikTok posters are mocked)
including its already-posted idempotency.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, patch
from roboco.runtime.orchestrator import _is_held_ceo_source, _is_non_dev_dispatch_source
from roboco.services.heartbeat_mutex import HeartbeatMutex
from roboco.services.video_post_service import (
TikTokPoster,
TikTokUploadResult,
XVideoPoster,
XVideoPostResult,
)
from tests.e2e_smoke.arcs import seed_company, seed_project, seed_task
from tests.e2e_smoke.harness import ScriptedAgent, expect_error
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from tests.e2e_smoke.arcs import Company
from tests.e2e_smoke.harness import E2EStack
def _seed_video_agents(stack: E2EStack) -> None:
"""Seed ``system`` / ``secretary-1`` / ``ux-dev-1`` / ``ux-dev-2`` at their
FIXED foundation UUIDs — the video engine writes ``created_by`` /
``assigned_to`` straight from the static identity registry (not a
role-keyed DB lookup), so those exact ids must exist as real agent rows
for the FK to resolve. Idempotent (safe if ever called more than once
against the same stack), mirroring
``test_feature_spotlight._seed_system_and_secretary``.
"""
from roboco.db.tables import AgentTable
from roboco.foundation import identity as _foundation
from roboco.models import AgentRole, AgentStatus, Team
async def _run(session: AsyncSession) -> None:
for agent_uuid, slug, role, team in (
(_foundation.AGENTS["system"].uuid, "system", AgentRole.SYSTEM, None),
(
_foundation.AGENTS["secretary-1"].uuid,
"secretary-1",
AgentRole.SECRETARY,
None,
),
(
_foundation.AGENTS["ux-dev-1"].uuid,
"ux-dev-1",
AgentRole.DEVELOPER,
Team.UX_UI,
),
(
_foundation.AGENTS["ux-dev-2"].uuid,
"ux-dev-2",
AgentRole.DEVELOPER,
Team.UX_UI,
),
):
if await session.get(AgentTable, agent_uuid) is not None:
continue
session.add(
AgentTable(
id=agent_uuid,
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt=slug,
capabilities=[],
permissions={},
metrics={},
)
)
stack.run_db(_run)
def _seed_completed_authoring_task(stack: E2EStack, project_id: Any) -> UUID:
"""A completed ``source=video`` authoring task carrying a proposed
composition — the render loop's scan basis. Mirrors the shape a real
``VideoEngine.open_video_task`` + ``propose_video`` call would leave
behind, seeded directly (the harness's own convention for mid-flight
setup — see ``arcs.seed_hierarchy``); the render/approve/gate wiring
under test doesn't depend on how the authoring task got here.
"""
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers as _markers
from roboco.models import Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
from roboco.services.task import VIDEO_SOURCE
draft: dict[str, Any] = {
"occasion": "e2e pipeline test",
"script": "Here's what shipped",
"brief": "Announce the e2e video pipeline",
"composition_id": "Intro",
"input_props": {"title": "hello"},
"x_caption": "Check out our new release!",
"tiktok_caption": "New release, check it out",
"platforms": ["x", "tiktok"],
}
task_id: UUID = seed_task(
stack,
title="Video: e2e pipeline test",
description="Announce the e2e video pipeline",
acceptance_criteria=["Both 9:16 and 1:1 cuts render"],
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.LOW,
team=Team.UX_UI,
project_id=project_id,
created_by=_foundation.AGENTS["system"].uuid,
assigned_to=_foundation.AGENTS["ux-dev-1"].uuid,
status=TaskStatus.COMPLETED,
source=VIDEO_SOURCE,
confirmed_by_human=True,
orchestration_markers={_markers.VIDEO_DRAFT: draft},
)
return task_id
class _FakeRenderer:
"""Stands in for the video-renderer sidecar: returns a deterministic
path per orientation, no tar/HTTP anywhere."""
async def render(
self,
*,
source_dir: str,
composition_id: str,
input_props: dict[str, Any],
orientation: str,
render_key: str,
) -> str:
_ = (source_dir, input_props)
return f"/fake-out/{render_key}-{composition_id}-{orientation}.mp4"
def _render_completed_task(stack: E2EStack, task_id: UUID) -> None:
"""Drive the REAL orchestrator render step against the completed
authoring task — only the sidecar client + workspace read-clone are
mocked (the render step's external-I/O boundary)."""
from pathlib import Path as _Path
from roboco.db.tables import TaskTable
from roboco.runtime.orchestrator import AgentOrchestrator
from sqlalchemy import select
workspace = SimpleNamespace(
ensure_read_clone=AsyncMock(return_value=_Path("/fake-clone"))
)
orch = AgentOrchestrator.__new__(AgentOrchestrator)
async def _run(session: AsyncSession) -> None:
row = (
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
).scalar_one()
with (
patch(
"roboco.services.video_renderer_client.get_video_renderer",
_FakeRenderer,
),
patch(
"roboco.services.workspace.get_workspace_service",
lambda _db: workspace,
),
):
await orch._render_video_task(session, row)
stack.run_db(_run)
def _task_dict(stack: E2EStack, task_id: UUID) -> dict[str, Any]:
"""The (source, confirmed_by_human) shape a dispatcher reads off a task
— real committed values, not a hand-crafted stand-in."""
from roboco.db.tables import TaskTable
from sqlalchemy import select
async def _run(session: AsyncSession) -> dict[str, Any]:
row = (
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
).scalar_one()
return {
"source": row.source,
"confirmed_by_human": row.confirmed_by_human,
"status": str(row.status),
}
result: dict[str, Any] = stack.run_db(_run)
return result
def _find_video_post_draft(stack: E2EStack, source_task_id: UUID) -> dict[str, Any]:
"""The held video_post draft the render step materialized for
``source_task_id`` — located via the marker's own back-reference
(``_originate_video_post`` stamps ``source_task_id``), robust against any
other video_post rows in this session-scoped shared test DB."""
from roboco.foundation.policy.content import markers as _markers
from roboco.services.task import get_task_service
async def _run(session: AsyncSession) -> dict[str, Any]:
drafts = await get_task_service(session).list_open_video_post_drafts()
match = next(
t
for t in drafts
if (_markers.get_video_draft(t) or {}).get("source_task_id")
== str(source_task_id)
)
draft = _markers.get_video_draft(match) or {}
return {
"id": match.id,
"source": match.source,
"confirmed_by_human": match.confirmed_by_human,
"status": str(match.status),
"mp4_paths": dict(draft.get("mp4_paths") or {}),
}
result: dict[str, Any] = stack.run_db(_run)
return result
class _FakeXPoster(XVideoPoster):
@property
def configured(self) -> bool:
return True
async def post_video(self, *, mp4_path: str, caption: str) -> XVideoPostResult:
_ = (mp4_path, caption)
return XVideoPostResult(posted=True, video_id="e2e-x-vid", detail="posted")
class _FakeTikTokPoster(TikTokPoster):
@property
def configured(self) -> bool:
return True
async def upload_to_inbox(
self, *, mp4_path: str, caption: str
) -> TikTokUploadResult:
_ = (mp4_path, caption)
return TikTokUploadResult(
uploaded=True, publish_id="e2e-tt-pub", detail="uploaded"
)
# No real Redis in this harness (and the root conftest's autouse fixture
# points settings.redis_url at an unreachable port for every test regardless)
# — mocked the same way tests/integration/test_video_routes.py does.
_LOCKED = (
patch.object(HeartbeatMutex, "acquire", AsyncMock(return_value="e2e-lock-token")),
patch.object(HeartbeatMutex, "release", AsyncMock(return_value=None)),
)
def _approve(stack: E2EStack, draft_id: UUID) -> dict[str, Any]:
from roboco.services.video_post_service import get_video_post_service
async def _run(session: AsyncSession) -> dict[str, Any]:
svc = get_video_post_service(
session, x_poster=_FakeXPoster(), tiktok_poster=_FakeTikTokPoster()
)
result = await svc.approve(draft_id)
assert result is not None
return {"status": result.status, "posted": dict(result.posted)}
with _LOCKED[0], _LOCKED[1]:
outcome: dict[str, Any] = stack.run_db(_run)
return outcome
def test_video_pipeline_render_and_approve(e2e_stack: E2EStack) -> None:
stack = e2e_stack
company: Company = seed_company(stack)
_seed_video_agents(stack)
project_id, _project_slug = seed_project(stack, company)
task_id = _seed_completed_authoring_task(stack, project_id)
# The exact bug class test_feature_spotlight.py guards against: a verb
# granted (role_config) + implemented (ContentActions) + routed
# (api/v1/do) but missing from do_server's _TOOLS/_REGISTERED_TOOLS is
# silently uncallable over MCP no matter what the gateway layers say.
dev = ScriptedAgent(stack, company.dev_id, "be-dev-1", "developer")
do_module = dev._module("roboco.mcp.do_server")
assert "propose_video" in do_module._TOOLS, (
"propose_video missing from do_server._TOOLS — no role could ever "
"call it over MCP"
)
assert "propose_video" in do_module._REGISTERED_TOOLS, (
"propose_video is granted to developer in role_config but absent "
"from this agent's _register_tools() output"
)
# propose_video is granted to every developer role (be/fe/ux-dev share
# Role.DEVELOPER) — the runtime TEAM gate is the real enforcement, so a
# be-dev's call must be rejected even though the tool is on their manifest.
env = dev.do(
"propose_video",
composition_id="Intro",
x_caption="Check it out",
tiktok_caption="Check it out on TikTok",
platforms=["x"],
)
expect_error(env, "not_authorized", "be-dev propose_video team gate")
# Contrast case: the authoring task's own source is normal delivery work
# (confirmed_by_human=True) — neither dispatcher treats it as held.
before = _task_dict(stack, task_id)
assert before["status"] == "completed", before
assert _is_non_dev_dispatch_source(before) is False, before
assert _is_held_ceo_source(before) is False, before
# The render loop: only the sidecar client + workspace read-clone mocked.
_render_completed_task(stack, task_id)
draft = _find_video_post_draft(stack, task_id)
assert draft["source"] == "video_post", draft
assert draft["confirmed_by_human"] is False, draft
assert draft["status"] == "pending", draft # held, awaiting the CEO
assert set(draft["mp4_paths"]) == {"vertical", "square"}, draft
# The key wiring: video_post is skipped by BOTH dispatchers.
held_shape = {
"source": draft["source"],
"confirmed_by_human": draft["confirmed_by_human"],
}
assert _is_non_dev_dispatch_source(held_shape) is True, held_shape
assert _is_held_ceo_source(held_shape) is True, held_shape
# VideoPostService.approve posts via mocked X-v2 + TikTok posters, then is
# idempotent on a second call (no re-post, same ids returned).
first = _approve(stack, draft["id"])
assert first["status"] == "posted", first
assert first["posted"] == {"x": "e2e-x-vid", "tiktok": "e2e-tt-pub"}, first
second = _approve(stack, draft["id"])
assert second["status"] == "already_posted", second
assert second["posted"] == first["posted"], second