feat(video): 0.19.0 video engine (Remotion) + preview auth + render persistence (#307)

* feat(video): Phase A — VideoEngine origination spine + held-source gates

New default-off engine skeleton: opens a UX/UI authoring task (source=video, assigned to a ux-dev, LOW complexity to clear the dev-needs-subtasks guard) and materializes a held CEO-approval draft (source=video_post). Excludes video_post from all three held-source skip sites; adds the video_draft marker, six config flags, and the feature-flag entries. Origination + gate behavior unit-tested.

* refactor(orchestrator): fold _dispatch_dev_work skip chain into a helper

The per-source if/continue chain grew past xenon's --max-absolute B when the video_post held source joined it. Extract _is_non_dev_dispatch_source (every held-CEO source plus the two Board exploration sources) so the dev loop's skip is one flat call. Behavior-identical.

* feat(video): Phase B — propose_video do-tool (metadata-only, team-gated)

UX/UI dev records a video's composition ref + per-platform captions onto the authoring task's video_draft marker. Team-gated at runtime via _caller_team (Role.DEVELOPER can't tell a ux-dev from a be-dev). Resolves the caller's ACTIVE task via get_active_task_for_agent, not an oldest-first scan that would clobber a second open video task. Metadata only, no render. Wired through do_server + route + schema; added to _DEV_DO.

* feat(video): Phase D — render loop + RemotionRenderer client

Orchestrator-async _video_render_loop renders a completed authoring task's merged composition to MP4 (vertical + square) via the remotion-renderer sidecar and materializes the held video_post draft. RemotionRenderer tars the read-clone's motion/ source, POSTs it, and saves the returned MP4 bytes to a TASK-scoped local path (no shared volume; a composition is reused across videos so a composition-scoped path would clobber an earlier draft). Render failures bounded-retry (read-clone catch-up window, transient sidecar) up to a cap, then terminal-fail. Client tested vs a mock transport; loop vs a mock renderer + real DB.

* feat(video): Phase C — release / spotlight / on-demand video triggers

Three entry points open a UX/UI video-authoring task via VideoEngine.open_video_task: (1) a published release drafts a companion video — best-effort in ReleaseProposalService.approve, never fails the publish; script from the CHANGELOG via the local model with a template fallback. (2) propose_feature_spotlight gains optional wants_video/video_script — best-effort, gated on video_on_spotlight, default-off leaves the spotlight flow byte-for-byte unchanged. (3) POST /video/request (CEO-only) for an on-demand brief, with clean disabled/not_opened responses. All gated on video_engine_enabled.

* fix(video): savepoint-isolate video-task inserts (F042 poisoned session)

The best-effort try/except around open_video_task (release-publish + spotlight hooks) swallowed the Python exception, but a DBAPI error at the insert flush left the shared session must-rollback — so the caller's next commit (release finalize / request boundary) threw PendingRollbackError: the release stuck 'pending' after actually publishing, or the spotlight draft + HTTP response were lost. Wrap both inserts (open_video_task, _originate_video_post) in a begin_nested savepoint (the repo's established F042 pattern) so a DB error rolls back only the insert. open_video_task returns None (every caller already handles it); _originate_video_post propagates to the render loop's handler. Regression test: an insert FK error returns None with the session left usable. Dormant while the flags were off; armed on the NAS.

* feat(video): Phase G — motion/ package + remotion-renderer sidecar + compose

In-repo Remotion v4 motion/ package (ReleaseAnnouncement composition; calculateMetadata returns 1080x1920 vertical / 1080x1080 square from inputProps.orientation) + a credential-free remotion-renderer sidecar: untar the POSTed motion/ source, bundle (LRU-cached per source sha), selectComposition + renderMedia h264, stream the MP4 bytes back — matching the RemotionRenderer client contract. docker/remotion.Dockerfile on Debian (Chrome apt deps, build-time Chrome pre-warm, ffmpeg bundled in @remotion/renderer). Wired into both compose files (roboco_default only, shm_size 1gb, /health check) + the release publish matrix. Verified via a real local render of both cuts; the Debian docker build is the CEO's to run.

* chore(video): D-hardening — video_post source_task_id + render-loop docstring

Add a source_task_id back-reference to the video_post held-draft marker (traceability from a draft to its authoring task; also makes the render loop's two-key idempotency check wireable later). Fix the render-loop test's stale docstring ('never retried' -> bounded-retry). Both from the Phase D critic's non-blocking follow-ups.

* feat(video): Phase E1 — VideoPostService + heartbeat mutex (approve->post)

CEO-approve->post service: heartbeat-renewed Redis mutex (fail-closed, grace=ttl-2*heartbeat), re-read-in-lock double-post guard, per-platform durable commits (asyncio.shield-ed, settle-before-rollback on lock-loss), all writes inside the lock (captions validated pre-lock, applied in-lock — no stale whole-column clobber), idempotent, per-platform retry-skip. Poster interfaces (X/TikTok, mocked here). Reject + list-held-drafts. Survived 3 adversarial rounds; residual = a crash in the poster->commit window (CEO-gated low-freq, documented).

* fix(video): G-hardening — renderer leaks + Share Tech Mono brand font

Sidecar: give bundle() an explicit outDir tracked + deleted on LRU eviction (was leaking ~19MB remotion-webpack-bundle-* per source); res.on('close') cleanup so an aborted/retried download no longer leaks its remotion-out-* MP4 dir. Fonts: vendor Share Tech Mono (roboco-website brand font) as the display face (self-hosted woff2, 400-weight, headline fontWeight 700->400 to avoid faux-bold) + self-hosted Inter body — no gstatic fetch at render time (lsof-verified). Extras: composition_id whitelist (400) + Multer error middleware (400/413).

* feat(video): Phase E2 — X v2 + TikTok posters, tiktok_credentials, routes

LiveXVideoPoster (X v2 chunked media upload: init/append/finalize/STATUS-poll -> tweet w/ media_ids, OAuth1 signer reused). LiveTikTokPoster (OAuth2 inbox: init -> chunked PUT with asymmetric final chunk -> status-fetch; 401 -> refresh_token grant, rotated token persisted). tiktok_credentials Fernet singleton + migration 062 (single head). Routes: CEO approve/reject + list held drafts + write-only tiktok creds, wiring real posters into VideoPostService. Residual: a lock-loss right after a token-refresh flush can discard the rotated token (same rare CEO-gated class as the documented post->commit window).

* feat(video): Phase F — panel video-post queue + TikTok creds card + flags

video-post-queue.tsx: <video> MP4 preview with 9:16/1:1 cut switch, per-platform editable captions (280/2200 counters, over-limit disables approve), approve/reject, Request-a-video dialog. tiktok-credentials-card.tsx (4 write-only OAuth2 fields). feature-flags-card inlines TikTokCredentialsForm under video_engine_enabled. Mounted in command-center. tsc/eslint clean, 273 panel tests green. NOTE: needs the GET /video/posts/{id}/media route + mp4_paths on VideoPostResponse (folded into H) for the preview source.

* feat(video): Phase H — media route + e2e smoke + NAS arming + docs

GET /video/posts/{id}/media?cut= (CEO-gated FileResponse of the rendered MP4; closes the panel preview gap) + mp4_paths on VideoPostResponse. e2e smoke tests/e2e_smoke/test_video_pipeline.py (full flow, sidecar+X/TikTok mocked; asserts dispatcher skips, render-loop materialize, propose_video team-gate, approve idempotency). NAS arming: docker-compose.yml/.yaml ROBOCO_VIDEO_ENGINE_ENABLED/ON_RELEASE/ON_SPOTLIGHT default-on (.yaml resynced to .yml); registry stays off. CLAUDE.md video-engine section + CHANGELOG. Fixed 2 pre-existing route-test pollution leaks. Full suite 11763 passed.

* fix(video): auth-carrying preview, media route confinement, VideoPost type drift

Three fixes along the video preview path:

1. panel video preview auth: the <video> element was pointed straight at
   GET /video/posts/{id}/media, but a native <video src> GET carries none
   of axios's X-Agent-ID/X-Agent-Role headers — so in the default
   header-trust deployment the request 401s. Fetch the cut via
   videoApi.getMediaBlob (axios, responseType: blob) and drive <video>
   off a URL.createObjectURL result instead. The object URL is revoked
   on cut-change (the previous cut's URL) and on unmount, so neither
   cut switches nor row teardown leak blob URLs.

2. backend media route confinement: GET /video/posts/{id}/media now
   resolves mp4_path and refuses it with 404 when it falls outside
   settings.video_output_dir. Defense-in-depth against any future
   writer of mp4_paths serving files from arbitrary disk locations.

3. panel VideoPost type/comment drift: added mp4_paths to the
   VideoPost interface (the committed VideoPostResponse already
   carries it), and corrected the stale comment on videoMediaUrl
   that claimed no route served the rendered bytes — the route has
   existed since the media endpoint landed; the comment now describes
   why getMediaBlob exists instead of a direct <video src>.

* Persist rendered videos to data in physical storage.

* ++

* docs(video): 0.18.0 CHANGELOG entry + RAG + map reference for video engine

- Move the video engine bullet from [Unreleased] into [0.18.0] and note
  the ROBOCO_VIDEO_OUTPUT_DIR bind-mount persistence.
- Add docs/rag/architecture/video-engine.md (mirrors x-engine.md shape:
  enable/disable, three triggers, render loop + sidecar, CEO gate, media
  route confinement, credentials).
- Reference the video render loop in docs/map/orchestrator.md's engine list.

* chore(video): re-bump to 0.19.0 + sync registry compose defaults

Version was wrongly bumped to 0.18.0; 0.18.0 is an already-released
section. Restore its 2026-07-04 date and move the video-engine CHANGELOG
bullet into a new [0.19.0] - 2026-07-05 section above it. Bump
pyproject.toml, roboco/__init__.py, roboco/config.py (app_version),
panel/package.json, and the motion/README inputProps example to 0.19.0.

docker-compose.registry.yml: add ROBOCO_VIDEO_ENGINE_ENABLED /
_VIDEO_ON_RELEASE / _VIDEO_ON_SPOTLIGHT defaulted false (NAS arms them
true), and comment out the video-renders bind mount with a short note
so the public registry image ships video off by default. Structural
sync with docker-compose.yml maintained.

* fix(video): rate-limit /render + reflow motion/README

CodeQL flagged js/missing-rate-limiting on the renderer /render route.
The sidecar is container-network-only with one trusted caller (the
orchestrator, which renders cuts serially), so this limiter is a
retry-storm ceiling (30/min, well above legit render rate), not the
primary control. Also reflows motion/README.md hard-wrapped prose that
failed the markdown quality gate.

* fix(build): finish pnpm 11 migration + regen verb tables

The panel Docker image build failed on `pnpm install --frozen-lockfile`:
node:22-alpine's corepack resolved to its bundled pnpm 11, but
panel/package.json pinned packageManager to pnpm@10.25.0, and pnpm 11
refuses to run against that pin. The Dockerfiles were already written for
pnpm 11 (comments, CI=true, strictDepBuilds); the package.json pin was the
stale outlier. Finish the migration instead of working around it:

- panel/package.json: packageManager pnpm@10.25.0 -> pnpm@11.10.0; drop the
  `pnpm` field (pnpm 11 ignores it — build approval lives in
  panel/pnpm-workspace.yaml's allowBuilds). Lockfile unchanged (pnpm 11
  accepts it as-is); frozen-lockfile verified.
- remotion-renderer/package.json: pin packageManager pnpm@11.10.0 for
  determinism (was relying on corepack's implicit default); engines.node
  >=22.13 (pnpm 11 requirement).
- docker/panel.Dockerfile + docker/remotion.Dockerfile: `corepack prepare
  pnpm@11.10.0 --activate` so the build uses the pinned version explicitly
  instead of trusting corepack's bundled default (which a future
  node:22-alpine could change).
- .github/workflows/panel-ci.yml: Node 20 -> 22 (pnpm 11 requires
  Node >=22.13; Node 20 fails the engines check).

Also regenerate agents/prompts/_generated/{developer,head_marketing,verbs}.md
— the video engine added propose_video and extended propose_feature_spotlight
(wants_video, video_script) but the verb tables weren't refreshed, failing
the foundation-check quality gate.

* chore(build): approve esbuild build script in remotion pnpm-workspace.yaml

pnpm 11 generated this file with a placeholder ('set this to true or false')
during install; resolve it to true so local dev of the renderer doesn't
re-prompt. esbuild's postinstall only verifies the prebuilt platform binary
(@esbuild/<platform> is installed as an optional dep), so approving it is
safe and silences the ERR_PNPM_IGNORED_BUILDS warning.

* fix(build): copy pnpm-workspace.yaml into panel + remotion images

pnpm 11 hard-errors with [ERR_PNPM_IGNORED_BUILDS] (exit 1) when a
dependency ships a postinstall script that isn't approved in
allowBuilds. Both Dockerfiles copied only package.json + pnpm-lock.yaml,
so the build-approval map in pnpm-workspace.yaml never made it into the
image — the remotion image build died on esbuild@0.28.1's postinstall.

Copy pnpm-workspace.yaml alongside the manifests in both images. In
panel, this also drops the --config.strictDepBuilds=false workaround:
with sharp and unrs-resolver now approved, their postinstalls run and
install the platform-specific binaries (previously skipped, leaving
sharp without its @img/sharp-* binary at runtime).

Verified locally: remotion + panel `pnpm install --frozen-lockfile`
exit 0 with the workspace file present; both exit 1 without it.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-05 13:37:17 +02:00
committed by GitHub
co-authored by Renn F
parent 4b62b6278f
commit e9d0e0bd48
91 changed files with 15746 additions and 71 deletions
+359
View File
@@ -0,0 +1,359 @@
"""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 remotion-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 remotion-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.remotion_client.get_remotion_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
@@ -0,0 +1,127 @@
"""TikTokCredentialsService coverage — encrypt/roundtrip, all-or-nothing
set/clear, and the update_tokens refresh-rotation write.
Mirrors test_x_credentials_service.py. The service never returns plaintext
to a caller other than `get_decrypted` (the server-side-only reader) — the
API layer only ever sees `has_credentials`.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
import pytest_asyncio
from roboco.db.tables import TikTokCredentialsTable
from roboco.services.tiktok_credentials import (
TikTokCredentialsService,
TikTokCredentialsValidationError,
get_tiktok_credentials_service,
)
from sqlalchemy import select
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
_CREDS = {
"client_key": "ck-test",
"client_secret": "cs-test",
"access_token": "at-test",
"refresh_token": "rt-test",
}
@pytest_asyncio.fixture
async def svc(db_session: AsyncSession) -> AsyncIterator[TikTokCredentialsService]:
yield get_tiktok_credentials_service(db_session)
@pytest.mark.asyncio
async def test_unset_has_no_credentials(svc: TikTokCredentialsService) -> None:
assert await svc.has_credentials() is False
assert await svc.get_decrypted() is None
@pytest.mark.asyncio
async def test_set_all_four_encrypts_and_roundtrips(
svc: TikTokCredentialsService,
) -> None:
has_creds = await svc.set_credentials(**_CREDS)
assert has_creds is True
assert await svc.has_credentials() is True
decrypted = await svc.get_decrypted()
assert decrypted is not None
assert decrypted.client_key == _CREDS["client_key"]
assert decrypted.client_secret == _CREDS["client_secret"]
assert decrypted.access_token == _CREDS["access_token"]
assert decrypted.refresh_token == _CREDS["refresh_token"]
@pytest.mark.asyncio
async def test_stored_row_never_holds_plaintext(
svc: TikTokCredentialsService, db_session: AsyncSession
) -> None:
await svc.set_credentials(**_CREDS)
result = await db_session.execute(select(TikTokCredentialsTable).limit(1))
row = result.scalar_one_or_none()
assert row is not None
assert row.client_key_encrypted != _CREDS["client_key"]
assert row.client_secret_encrypted != _CREDS["client_secret"]
assert row.access_token_encrypted != _CREDS["access_token"]
assert row.refresh_token_encrypted != _CREDS["refresh_token"]
@pytest.mark.asyncio
async def test_clearing_all_four_removes_row(svc: TikTokCredentialsService) -> None:
await svc.set_credentials(**_CREDS)
has_creds = await svc.set_credentials(
client_key="", client_secret="", access_token="", refresh_token=""
)
assert has_creds is False
assert await svc.has_credentials() is False
assert await svc.get_decrypted() is None
@pytest.mark.asyncio
async def test_partial_set_is_rejected(svc: TikTokCredentialsService) -> None:
with pytest.raises(TikTokCredentialsValidationError):
await svc.set_credentials(
client_key="only-one", client_secret="", access_token="", refresh_token=""
)
@pytest.mark.asyncio
async def test_rotate_overwrites_previous_values(svc: TikTokCredentialsService) -> None:
await svc.set_credentials(**_CREDS)
rotated = {k: f"{v}-rotated" for k, v in _CREDS.items()}
await svc.set_credentials(**rotated)
decrypted = await svc.get_decrypted()
assert decrypted is not None
assert decrypted.client_key == rotated["client_key"]
@pytest.mark.asyncio
async def test_update_tokens_rotates_access_and_refresh_only(
svc: TikTokCredentialsService,
) -> None:
await svc.set_credentials(**_CREDS)
await svc.update_tokens(access_token="at-new", refresh_token="rt-new")
decrypted = await svc.get_decrypted()
assert decrypted is not None
assert decrypted.access_token == "at-new"
assert decrypted.refresh_token == "rt-new"
# client_key/client_secret are untouched by the narrower refresh write.
assert decrypted.client_key == _CREDS["client_key"]
assert decrypted.client_secret == _CREDS["client_secret"]
@pytest.mark.asyncio
async def test_update_tokens_before_any_credentials_set_raises(
svc: TikTokCredentialsService,
) -> None:
with pytest.raises(TikTokCredentialsValidationError):
await svc.update_tokens(access_token="at-new", refresh_token="rt-new")
+545
View File
@@ -0,0 +1,545 @@
"""Video engine route coverage — the on-demand request trigger, the held
video_post draft list/approve/reject queue, and the TikTok credentials
sub-router. CEO-only throughout."""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.video import router as video_router
from roboco.api.routes.video import tiktok_router
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType
from roboco.models.permissions import AgentContext
from roboco.services.heartbeat_mutex import HeartbeatMutex
from roboco.services.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, get_task_service
from roboco.services.tiktok_credentials import get_tiktok_credentials_service
from roboco.services.video_post_service import XVideoPostResult
from roboco.services.x_credentials import get_x_credentials_service
from roboco.services.x_video_client import LiveXVideoPoster
from sqlalchemy import delete, select
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
SLUG = "roboco-video-route-test"
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
UX_DEV_1_UUID = _foundation.AGENTS["ux-dev-1"].uuid
UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
async def _seed(session: AsyncSession) -> None:
for uuid_, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(UX_DEV_1_UUID, "ux-dev-1", AgentRole.DEVELOPER, Team.UX_UI),
(UX_DEV_2_UUID, "ux-dev-2", AgentRole.DEVELOPER, Team.UX_UI),
):
if await session.get(AgentTable, uuid_) is None:
session.add(
AgentTable(
id=uuid_,
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
existing = await session.execute(
select(ProjectTable).where(ProjectTable.slug == SLUG)
)
if existing.scalar_one_or_none() is None:
session.add(
ProjectTable(
name="RoboCo",
slug=SLUG,
git_url="https://github.com/x/roboco.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
)
)
await session.flush()
async def _seed_agent(session: AsyncSession, role: AgentRole, slug: str) -> AgentTable:
agent = AgentTable(
id=uuid4(),
name=slug,
slug=f"{slug}-{uuid4().hex[:6]}",
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
session.add(agent)
await session.flush()
return agent
async def _seed_draft(
session: AsyncSession,
*,
platforms: list[str] | None = None,
mp4_paths: dict[str, str] | None = None,
) -> TaskTable:
"""A held ``video_post`` draft — the approve/reject/list queue basis."""
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
secretary = await _seed_agent(session, AgentRole.SECRETARY, "secretary")
project = ProjectTable(
id=uuid4(),
name="RoboCo",
slug=f"roboco-{uuid4().hex[:6]}",
git_url="https://example.com/roboco.git",
assigned_cell=Team.BACKEND,
created_by=system.id,
)
session.add(project)
await session.flush()
task = TaskTable(
id=uuid4(),
title="Video post: release 1.0",
description="script",
acceptance_criteria=["CEO approves or rejects the draft"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.ADMINISTRATIVE,
nature=TaskNature.NON_TECHNICAL,
estimated_complexity=Complexity.LOW,
project_id=project.id,
created_by=system.id,
assigned_to=secretary.id,
team=Team.MAIN_PM,
source=VIDEO_POST_SOURCE,
confirmed_by_human=False,
)
session.add(task)
await session.flush()
markers.set_video_draft(
task,
{
"occasion": "release 1.0",
"script": "script",
"platforms": platforms if platforms is not None else ["x"],
"mp4_paths": mp4_paths
if mp4_paths is not None
else {
"square": "/render/out/1-square.mp4",
"vertical": "/render/out/1-vertical.mp4",
},
"x_caption": "Check out this clip",
"tiktok_caption": "Check out this clip on TikTok",
"render_status": "rendered",
},
)
await session.flush()
return task
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
app = FastAPI()
app.include_router(video_router, prefix="/api/video")
app.include_router(tiktok_router, prefix="/api/tiktok")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=agent_id, role=role, team=None)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
return app
@pytest_asyncio.fixture
async def ceo_client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
app = _build_app(db_session, AgentRole.CEO, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
_LOCKED = (
patch.object(HeartbeatMutex, "acquire", AsyncMock(return_value="tok")),
patch.object(HeartbeatMutex, "release", AsyncMock(return_value=None)),
)
@pytest.mark.asyncio
async def test_request_video_opens_authoring_task(
db_session: AsyncSession, ceo_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
monkeypatch.setattr(cfg, "video_max_open_posts", 5)
resp = await ceo_client.post(
"/api/video/request",
json={
"occasion": "CEO on-demand: launch teaser",
"brief": "A short teaser for the new dashboard",
"platforms": ["x", "tiktok"],
},
)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["status"] == "opened"
assert body["task_id"] is not None
try:
# The route commits (mirrors the X route), so identity/field checks on
# the specific created row — not a global open-list count — keep this
# robust against other committed rows in the shared session-scoped
# test DB.
task = await db_session.get(TaskTable, UUID(body["task_id"]))
assert task is not None
assert task.source == VIDEO_SOURCE
assert task.status == TaskStatus.PENDING
finally:
# The route's commit durably persists this task past this test's own
# rollback teardown — a non-terminal source=video row left behind
# pollutes every later test in this session that counts open video
# tasks (test_video_engine.py / test_video_render_loop.py), so it
# must be deleted explicitly, not just rolled back.
await db_session.execute(
delete(TaskTable).where(TaskTable.id == UUID(body["task_id"]))
)
await db_session.commit()
@pytest.mark.asyncio
async def test_request_video_disabled_returns_clear_response(
db_session: AsyncSession, ceo_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
monkeypatch.setattr(cfg, "video_engine_enabled", False)
before = len(await get_task_service(db_session).list_open_video_posts())
resp = await ceo_client.post(
"/api/video/request",
json={"occasion": "occ-disabled", "brief": "brief", "platforms": ["x"]},
)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["status"] == "disabled"
assert body["task_id"] is None
after = len(await get_task_service(db_session).list_open_video_posts())
assert after == before # nothing new was opened
@pytest.mark.asyncio
async def test_request_video_not_opened_when_project_unresolvable(
db_session: AsyncSession, ceo_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An unresolvable project makes open_video_task no-op — a clear
``not_opened`` response, not a 500 or a fabricated task."""
await _seed(db_session)
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "self_heal_project_slug", "no-such-project")
before = len(await get_task_service(db_session).list_open_video_posts())
resp = await ceo_client.post(
"/api/video/request",
json={"occasion": "occ-unresolvable", "brief": "brief", "platforms": ["x"]},
)
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["status"] == "not_opened"
assert body["task_id"] is None
after = len(await get_task_service(db_session).list_open_video_posts())
assert after == before # nothing new was opened
@pytest.mark.asyncio
async def test_list_posts_returns_open_draft(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_draft(db_session)
resp = await ceo_client.get("/api/video/posts")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert len(body) == 1
assert body[0]["task_id"] == str(task.id)
assert body[0]["occasion"] == "release 1.0"
assert body[0]["platforms"] == ["x"]
assert body[0]["mp4_paths"] == {
"square": "/render/out/1-square.mp4",
"vertical": "/render/out/1-vertical.mp4",
}
@pytest.mark.asyncio
async def test_media_returns_the_rendered_cut(
db_session: AsyncSession,
ceo_client: AsyncClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "video_output_dir", str(tmp_path))
vertical = tmp_path / "clip-vertical.mp4"
vertical.write_bytes(b"fake-mp4-bytes-vertical")
task = await _seed_draft(
db_session,
mp4_paths={"vertical": str(vertical), "square": str(tmp_path / "missing.mp4")},
)
resp = await ceo_client.get(f"/api/video/posts/{task.id}/media?cut=vertical")
assert resp.status_code == HTTPStatus.OK
assert resp.headers["content-type"] == "video/mp4"
assert resp.content == b"fake-mp4-bytes-vertical"
@pytest.mark.asyncio
async def test_media_outside_output_dir_is_404(
db_session: AsyncSession,
ceo_client: AsyncClient,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A mp4_paths entry that resolves outside video_output_dir is refused
even though the file exists on disk — defense-in-depth against any
future writer of mp4_paths."""
outside = tmp_path / "outside" / "clip-vertical.mp4"
outside.parent.mkdir(parents=True)
outside.write_bytes(b"fake-mp4-bytes")
monkeypatch.setattr(cfg, "video_output_dir", str(tmp_path / "confined"))
task = await _seed_draft(db_session, mp4_paths={"vertical": str(outside)})
resp = await ceo_client.get(f"/api/video/posts/{task.id}/media?cut=vertical")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_media_bad_cut_is_400(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_draft(db_session)
resp = await ceo_client.get(f"/api/video/posts/{task.id}/media?cut=diagonal")
assert resp.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_media_missing_task_is_404(ceo_client: AsyncClient) -> None:
resp = await ceo_client.get(f"/api/video/posts/{uuid4()}/media?cut=vertical")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_media_unrendered_cut_is_404(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""The seeded draft's paths never exist on disk — a 404, not a crash."""
task = await _seed_draft(db_session)
resp = await ceo_client.get(f"/api/video/posts/{task.id}/media?cut=square")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_approve_without_credentials_fails_gracefully(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""No X/TikTok credentials configured in this test DB: the route still
builds real (Null) posters and the approve completes without raising —
just with nothing posted."""
task = await _seed_draft(db_session, platforms=["x", "tiktok"])
try:
with _LOCKED[0], _LOCKED[1]:
resp = await ceo_client.post(f"/api/video/posts/{task.id}/approve", json={})
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["status"] == "post_failed"
assert body["posted"] == {}
await db_session.refresh(task)
assert task.status == TaskStatus.PENDING # never advanced without a real post
finally:
# The approve route commits durably even on a post_failed outcome, so
# this non-terminal source=video_post row survives this test's own
# rollback teardown — left behind, it pollutes every later test in
# this session that counts open video tasks.
await db_session.execute(delete(TaskTable).where(TaskTable.id == task.id))
await db_session.commit()
@pytest.mark.asyncio
async def test_approve_with_credentials_posts_via_the_real_poster_wiring(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""Once X credentials are configured, the route builds a LiveXVideoPoster
(not the Null default) — the network call itself is mocked here; the
real HTTP sequence is covered by test_x_video_client.py.
The approve route commits durably (mirrors the X-post pattern), so the
x_credentials singleton row must be cleared afterward — left behind, it
leaks into any later test in this shared session-scoped test DB that
asserts a fresh "unset" state (e.g. test_x_credentials_service.py)."""
task = await _seed_draft(db_session, platforms=["x"])
creds_svc = get_x_credentials_service(db_session)
await creds_svc.set_credentials(
api_key="ak", api_secret="as", access_token="at", access_token_secret="ats"
)
try:
with (
_LOCKED[0],
_LOCKED[1],
patch.object(
LiveXVideoPoster,
"post_video",
AsyncMock(
return_value=XVideoPostResult(
posted=True, video_id="xid1", detail="posted"
)
),
),
):
resp = await ceo_client.post(f"/api/video/posts/{task.id}/approve", json={})
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert body["status"] == "posted"
assert body["posted"] == {"x": "xid1"}
await db_session.refresh(task)
assert task.status == TaskStatus.COMPLETED
finally:
await creds_svc.set_credentials(
api_key="", api_secret="", access_token="", access_token_secret=""
)
await db_session.commit()
@pytest.mark.asyncio
async def test_approve_edited_x_caption_over_limit_is_422(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_draft(db_session)
resp = await ceo_client.post(
f"/api/video/posts/{task.id}/approve", json={"x_caption": "x" * 281}
)
assert resp.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
@pytest.mark.asyncio
async def test_approve_missing_task_is_404(ceo_client: AsyncClient) -> None:
resp = await ceo_client.post(f"/api/video/posts/{uuid4()}/approve", json={})
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_reject_cancels_and_records_reason(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
task = await _seed_draft(db_session)
resp = await ceo_client.post(
f"/api/video/posts/{task.id}/reject", json={"reason": "Not our voice"}
)
assert resp.status_code == HTTPStatus.OK
assert resp.json()["reject_reason"] == "Not our voice"
refreshed = await db_session.get(TaskTable, task.id)
assert refreshed is not None
assert refreshed.status == TaskStatus.CANCELLED
@pytest.mark.asyncio
async def test_reject_missing_task_is_404(ceo_client: AsyncClient) -> None:
resp = await ceo_client.post(
f"/api/video/posts/{uuid4()}/reject", json={"reason": "not relevant here"}
)
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_tiktok_credentials_default_is_unset(ceo_client: AsyncClient) -> None:
resp = await ceo_client.get("/api/tiktok/credentials")
assert resp.status_code == HTTPStatus.OK
assert resp.json()["has_credentials"] is False
@pytest.mark.asyncio
async def test_set_tiktok_credentials_reports_status_never_plaintext(
db_session: AsyncSession, ceo_client: AsyncClient
) -> None:
"""The route commits durably, so the tiktok_credentials singleton row is
cleared afterward — left behind, it leaks into any later test in this
shared session-scoped test DB (e.g. test_tiktok_credentials_service.py's
"unset" assertions)."""
try:
resp = await ceo_client.post(
"/api/tiktok/credentials",
json={
"client_key": "secret-key-value",
"client_secret": "secret-clientsecret-value",
"access_token": "secret-token-value",
"refresh_token": "secret-refresh-value",
},
)
assert resp.status_code == HTTPStatus.OK
assert resp.json() == {"has_credentials": True}
assert "secret-key-value" not in resp.text
assert "secret-clientsecret-value" not in resp.text
assert "secret-token-value" not in resp.text
assert "secret-refresh-value" not in resp.text
status_resp = await ceo_client.get("/api/tiktok/credentials")
assert status_resp.json()["has_credentials"] is True
finally:
await get_tiktok_credentials_service(db_session).set_credentials(
client_key="", client_secret="", access_token="", refresh_token=""
)
await db_session.commit()
@pytest.mark.asyncio
async def test_set_tiktok_credentials_partial_is_400(ceo_client: AsyncClient) -> None:
resp = await ceo_client.post(
"/api/tiktok/credentials",
json={
"client_key": "only-one",
"client_secret": "",
"access_token": "",
"refresh_token": "",
},
)
assert resp.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.asyncio
async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
await _seed(db_session)
task = await _seed_draft(db_session)
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
request_resp = await client.post(
"/api/video/request",
json={"occasion": "occ", "brief": "brief", "platforms": ["x"]},
)
list_resp = await client.get("/api/video/posts")
media_resp = await client.get(f"/api/video/posts/{task.id}/media?cut=vertical")
creds_resp = await client.get("/api/tiktok/credentials")
assert request_resp.status_code == HTTPStatus.FORBIDDEN
assert list_resp.status_code == HTTPStatus.FORBIDDEN
assert media_resp.status_code == HTTPStatus.FORBIDDEN
assert creds_resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
+54
View File
@@ -171,6 +171,60 @@ async def test_evidence_with_task_id_returns_evidence_envelope() -> None:
assert str(mock_actions.evidence.call_args.kwargs["task_id"]) == _TASK_ID
@pytest.mark.asyncio
async def test_propose_feature_spotlight_defaults_wants_video_false() -> None:
"""POST /api/v1/do/propose_feature_spotlight without wants_video/video_script
threads the schema defaults through to the verb unchanged."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.propose_feature_spotlight = AsyncMock(
return_value=_make_envelope(status="feature_spotlight_proposed")
)
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v1/do/propose_feature_spotlight",
json={
"feature_slug": "org-memory",
"feature_title": "Organizational Memory Loop",
"body": "Did you know RoboCo agents learn from every task?",
},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
mock_actions.propose_feature_spotlight.assert_awaited_once()
kwargs = mock_actions.propose_feature_spotlight.call_args.kwargs
assert kwargs["wants_video"] is False
assert kwargs["video_script"] == ""
@pytest.mark.asyncio
async def test_propose_feature_spotlight_threads_wants_video_and_script() -> None:
"""Explicit wants_video=True + video_script reach the verb call verbatim."""
mock_actions = MagicMock(spec=ContentActions)
mock_actions.propose_feature_spotlight = AsyncMock(
return_value=_make_envelope(status="feature_spotlight_proposed")
)
client = TestClient(_build_app(mock_actions))
resp = client.post(
"/api/v1/do/propose_feature_spotlight",
json={
"feature_slug": "org-memory",
"feature_title": "Organizational Memory Loop",
"body": "Did you know RoboCo agents learn from every task?",
"wants_video": True,
"video_script": "A custom voiceover script",
},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200
kwargs = mock_actions.propose_feature_spotlight.call_args.kwargs
assert kwargs["wants_video"] is True
assert kwargs["video_script"] == "A custom voiceover script"
@pytest.mark.asyncio
async def test_notify_dispatches_target_text_priority() -> None:
"""POST /api/v1/do/notify forwards target/text/priority to ContentActions."""
@@ -0,0 +1,45 @@
"""The video engine is gated by default-off config flags (mirrors the X engine)."""
from __future__ import annotations
import os
from unittest import mock
from roboco.config import Settings
from roboco.services.settings import FEATURE_FLAGS, validate_setting
_DEFAULT_MAX_OPEN = 5
def test_video_engine_disabled_by_default() -> None:
s = Settings()
assert s.video_engine_enabled is False
assert s.video_on_release is False
assert s.video_on_spotlight is False
assert s.video_max_open_posts == _DEFAULT_MAX_OPEN
def test_video_engine_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_VIDEO_ENGINE_ENABLED": "true"}):
assert Settings().video_engine_enabled is True
def test_video_on_release_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_VIDEO_ON_RELEASE": "true"}):
assert Settings().video_on_release is True
def test_video_on_spotlight_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_VIDEO_ON_SPOTLIGHT": "true"}):
assert Settings().video_on_spotlight is True
def test_video_engine_flags_registered_in_feature_flags() -> None:
keys = [key for key, _ in FEATURE_FLAGS]
assert "video_engine_enabled" in keys
assert "video_on_release" in keys
assert "video_on_spotlight" in keys
def test_video_engine_flag_validates_as_bool() -> None:
validate_setting("video_engine_enabled", "true")
@@ -79,6 +79,40 @@ def test_transition_note_roundtrip_keyed_by_event() -> None:
assert m.get_transition_note(t, "never_set") is None
def test_video_draft_roundtrip() -> None:
t = _task()
assert m.get_video_draft(t) is None
m.set_video_draft(
t,
{
"occasion": "release v1.0.0",
"script": "Here's what shipped...",
"platforms": ["x", "tiktok"],
"brief": "Announce the release",
},
)
draft = m.get_video_draft(t)
assert draft is not None
assert draft["occasion"] == "release v1.0.0"
assert draft["platforms"] == ["x", "tiktok"]
def test_video_draft_extended_not_replaced() -> None:
"""The render pass extends the authoring marker rather than clobbering it —
the caller is responsible for spreading the existing dict (set_video_draft
itself just reassigns whatever payload it is given)."""
t = _task()
m.set_video_draft(t, {"occasion": "spotlight: org-memory", "script": "x"})
existing = m.get_video_draft(t) or {}
m.set_video_draft(
t, {**existing, "mp4_paths": {"vertical": "a.mp4", "square": "b.mp4"}}
)
draft = m.get_video_draft(t)
assert draft is not None
assert draft["occasion"] == "spotlight: org-memory"
assert draft["mp4_paths"] == {"vertical": "a.mp4", "square": "b.mp4"}
def test_documenter_self_heal_head_supersede() -> None:
t = _task()
m.set_documenter(t, "doc-uuid")
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.config import settings as cfg
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
@@ -177,3 +178,142 @@ async def test_propose_feature_spotlight_materializes_new_draft_task(
feature_title="Organizational Memory Loop",
body="Did you know RoboCo agents learn from every completed task?",
)
# --------------------------------------------------------------------------- #
# wants_video companion — additive, default-False, best-effort
# --------------------------------------------------------------------------- #
def _mock_spotlight_materialization(monkeypatch: pytest.MonkeyPatch) -> Any:
"""Wire an open exploration + a materializing XEngine, mirroring the happy
path above, so wants_video tests only need to stub the video engine."""
agent_id = uuid4()
exploration = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_feature_explorations = AsyncMock(return_value=[exploration])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
materialized = _FakeTask(assigned_to=agent_id)
x_engine = MagicMock()
x_engine.is_feature_seen = AsyncMock(return_value=False)
x_engine.materialize_feature_spotlight = AsyncMock(return_value=materialized)
monkeypatch.setattr("roboco.services.x_engine.get_x_engine", lambda _s: x_engine)
return agent_id
def _mock_video_engine(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
video_engine = MagicMock()
video_engine.open_video_task = AsyncMock(return_value=MagicMock())
monkeypatch.setattr(
"roboco.services.video_engine.get_video_engine", lambda _s: video_engine
)
return video_engine
def _enable_video(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "video_on_spotlight", True)
@pytest.mark.asyncio
async def test_propose_feature_spotlight_wants_video_opens_video_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_enable_video(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs(), wants_video=True
)
assert env.error is None
video_engine.open_video_task.assert_awaited_once()
kwargs = video_engine.open_video_task.call_args.kwargs
assert kwargs["occasion"] == "spotlight org-memory"
assert kwargs["platforms"] == ["x", "tiktok"]
expected_brief = (
"Organizational Memory Loop: Did you know RoboCo agents learn from "
"every completed task?"
)
assert kwargs["brief"] == expected_brief
assert kwargs["script"] == expected_brief # falls back — no video_script given
@pytest.mark.asyncio
async def test_propose_feature_spotlight_wants_video_uses_explicit_script(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_enable_video(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id,
**_valid_kwargs(),
wants_video=True,
video_script="Custom voiceover script",
)
assert env.error is None
kwargs = video_engine.open_video_task.call_args.kwargs
assert kwargs["script"] == "Custom voiceover script"
assert kwargs["brief"] != "Custom voiceover script" # brief is always title:body
@pytest.mark.asyncio
async def test_propose_feature_spotlight_default_wants_video_false_skips_video(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Default False -> byte-for-byte unchanged spotlight behavior: the video
engine is never even looked up."""
_enable_video(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs()
)
assert env.error is None
assert env.status == "feature_spotlight_proposed"
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_propose_feature_spotlight_wants_video_but_flags_off_skips_video(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", False)
monkeypatch.setattr(cfg, "video_on_spotlight", False)
agent_id = _mock_spotlight_materialization(monkeypatch)
video_engine = _mock_video_engine(monkeypatch)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs(), wants_video=True
)
assert env.error is None
video_engine.open_video_task.assert_not_called()
@pytest.mark.asyncio
async def test_propose_feature_spotlight_video_failure_does_not_break_spotlight(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Best-effort: a video-engine blow-up must not surface as an error on the
spotlight verb — the spotlight draft already materialized."""
_enable_video(monkeypatch)
agent_id = _mock_spotlight_materialization(monkeypatch)
monkeypatch.setattr(
"roboco.services.video_engine.get_video_engine",
MagicMock(side_effect=RuntimeError("video-engine boom")),
)
env = await _actions("head_marketing").propose_feature_spotlight(
agent_id=agent_id, **_valid_kwargs(), wants_video=True
)
assert env.error is None
assert env.status == "feature_spotlight_proposed"
@@ -0,0 +1,288 @@
"""roboco.services.gateway.content_actions.propose_video — team-gated,
metadata-only video-authoring draft (no render)."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.foundation.policy.content import markers
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
class _FakeTask:
"""Minimal stand-in for the ORM TaskTable row — carries just what
``propose_video`` touches."""
def __init__(
self,
*,
assigned_to: Any,
source: str = "video",
task_id: Any = None,
draft: dict[str, Any] | None = None,
) -> None:
self.id = task_id or uuid4()
self.assigned_to = assigned_to
self.source = source
self.orchestration_markers = {"video_draft": draft} if draft else None
def _actions(role: str, team: str | None) -> ContentActions:
task = MagicMock()
agent = MagicMock()
agent.role = role
agent.team = team
task.agent_for = AsyncMock(return_value=agent)
task.session = MagicMock()
task.session.flush = AsyncMock()
deps = ContentActionsDeps(
task=task,
git=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
workspace=MagicMock(),
notifications=MagicMock(),
)
return ContentActions(deps)
def _valid_kwargs(**overrides: Any) -> dict[str, Any]:
kwargs: dict[str, Any] = {
"composition_id": "release-announcement-v1",
"x_caption": "We just shipped v1.0.0! Check out the new release.",
"tiktok_caption": "New release just dropped — here's what's inside.",
"platforms": ["x", "tiktok"],
}
kwargs.update(overrides)
return kwargs
def _mock_active_task(
monkeypatch: pytest.MonkeyPatch, task: _FakeTask | None
) -> MagicMock:
"""Stub the caller's currently-active task — the resolver propose_video uses
(``get_active_task_for_agent``), NOT an oldest-first scan over every open
video task."""
task_svc = MagicMock()
task_svc.get_active_task_for_agent = AsyncMock(return_value=task)
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
return task_svc
# --------------------------------------------------------------------------- #
# team gate
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_video_forbidden_for_backend_dev() -> None:
env = await _actions("developer", "backend").propose_video(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_propose_video_forbidden_for_frontend_dev() -> None:
env = await _actions("developer", "frontend").propose_video(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_propose_video_forbidden_with_no_team() -> None:
env = await _actions("developer", None).propose_video(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "not_authorized"
# --------------------------------------------------------------------------- #
# field validation
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_video_rejects_empty_composition_id() -> None:
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs(composition_id="")
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_video_rejects_over_280_x_caption() -> None:
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs(x_caption="z" * 281)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_video_rejects_over_2200_tiktok_caption() -> None:
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs(tiktok_caption="z" * 2201)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_video_rejects_empty_platforms() -> None:
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs(platforms=[])
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_video_rejects_unknown_platform() -> None:
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs(platforms=["instagram"])
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_video_rejects_partially_unknown_platform() -> None:
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs(platforms=["x", "instagram"])
)
assert env.error == "invalid_state"
# --------------------------------------------------------------------------- #
# authoring-task resolution — the caller's ACTIVE task, not an oldest-first scan
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_video_no_active_task_is_invalid_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_mock_active_task(monkeypatch, None)
env = await _actions("developer", "ux_ui").propose_video(
agent_id=uuid4(), **_valid_kwargs()
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_video_rejects_when_active_task_not_video(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The dev's currently-active task is an ordinary code task, not a video
authoring task — refuse rather than write video metadata onto unrelated
work."""
agent_id = uuid4()
other = _FakeTask(assigned_to=agent_id, source="chore")
_mock_active_task(monkeypatch, other)
env = await _actions("developer", "ux_ui").propose_video(
agent_id=agent_id, **_valid_kwargs()
)
assert env.error == "invalid_state"
assert markers.get_video_draft(other) is None
@pytest.mark.asyncio
async def test_propose_video_rejects_when_active_task_is_held_post(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A held video_post draft is never a dev's authoring task (Secretary-owned,
source video_post) — refused by the source check."""
agent_id = uuid4()
held = _FakeTask(assigned_to=agent_id, source="video_post")
_mock_active_task(monkeypatch, held)
env = await _actions("developer", "ux_ui").propose_video(
agent_id=agent_id, **_valid_kwargs()
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_video_targets_active_task_not_an_older_one(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression: a UX/UI dev routinely holds more than one open video task
(finish A -> submit to QA -> claim B). propose_video must write to the task
the dev is ACTIVELY working on (via get_active_task_for_agent), never
silently onto an older open one — which would clobber an already-submitted
draft and report a false success."""
agent_id = uuid4()
older = _FakeTask(
assigned_to=agent_id,
draft={"occasion": "occ-a", "composition_id": "already-submitted"},
)
active = _FakeTask(assigned_to=agent_id, draft={"occasion": "occ-b"})
task_svc = _mock_active_task(monkeypatch, active)
env = await _actions("developer", "ux_ui").propose_video(
agent_id=agent_id, **_valid_kwargs(composition_id="occ-b-composition")
)
assert env.error is None
task_svc.get_active_task_for_agent.assert_awaited_once_with(agent_id)
assert env.task_id == str(active.id)
active_draft = markers.get_video_draft(active)
assert active_draft is not None
assert active_draft["composition_id"] == "occ-b-composition"
older_draft = markers.get_video_draft(older)
assert older_draft is not None
assert older_draft["composition_id"] == "already-submitted" # untouched
# --------------------------------------------------------------------------- #
# happy path — marker merge
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_propose_video_merges_onto_active_task_draft(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
authoring = _FakeTask(
assigned_to=agent_id,
draft={"occasion": "release v1.0.0", "script": "script", "brief": "brief"},
)
_mock_active_task(monkeypatch, authoring)
env = await _actions("developer", "ux_ui").propose_video(
agent_id=agent_id, **_valid_kwargs(input_props={"title": "Launch"})
)
assert env.error is None
assert env.status == "video_proposed"
assert env.task_id == str(authoring.id)
draft = markers.get_video_draft(authoring)
assert draft is not None
# existing fields survive the merge
assert draft["occasion"] == "release v1.0.0"
assert draft["script"] == "script"
assert draft["brief"] == "brief"
# new fields land
assert draft["composition_id"] == "release-announcement-v1"
assert draft["input_props"] == {"title": "Launch"}
assert draft["x_caption"] == _valid_kwargs()["x_caption"]
assert draft["tiktok_caption"] == _valid_kwargs()["tiktok_caption"]
assert draft["platforms"] == ["x", "tiktok"]
@pytest.mark.asyncio
async def test_propose_video_defaults_input_props_to_empty_dict(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_id = uuid4()
authoring = _FakeTask(assigned_to=agent_id)
_mock_active_task(monkeypatch, authoring)
env = await _actions("developer", "ux_ui").propose_video(
agent_id=agent_id, **_valid_kwargs()
)
assert env.error is None
draft = markers.get_video_draft(authoring)
assert draft is not None
assert draft["input_props"] == {}
@@ -0,0 +1,24 @@
"""propose_video is granted to every developer (Role.DEVELOPER doesn't
distinguish ux-dev from be-dev/fe-dev) the REAL gate is the runtime
_caller_team check in ContentActions.propose_video, covered separately in
test_content_actions_video.py."""
from __future__ import annotations
from roboco.services.gateway.role_config import get_role_config
def test_developer_gets_propose_video() -> None:
assert "propose_video" in get_role_config("developer").do_tools
def test_qa_does_not_get_propose_video() -> None:
assert "propose_video" not in get_role_config("qa").do_tools
def test_documenter_does_not_get_propose_video() -> None:
assert "propose_video" not in get_role_config("documenter").do_tools
def test_head_marketing_does_not_get_propose_video() -> None:
assert "propose_video" not in get_role_config("head_marketing").do_tools
@@ -54,6 +54,7 @@ def _make_orchestrator() -> AgentOrchestrator:
"_x_mentions_task",
"_roadmap_engine_task",
"_x_feature_spotlight_task",
"_video_render_task",
):
setattr(orch, attr, None)
return orch
@@ -26,6 +26,10 @@ class TestBuildForRole:
# Issue #8: the developer manifest must carry `evidence` so the
# do-server registers mcp__roboco-do__evidence inside the container.
assert "evidence" in m.do_tools
# Every dev's manifest carries propose_video (role alone can't tell a
# ux-dev from a be-dev/fe-dev); the runtime _caller_team check is the
# real gate — see test_content_actions_video.py.
assert "propose_video" in m.do_tools
assert "Edit" in m.write_tools
assert m.subagent_allowed is False
assert m.subagent_model is None # devs don't dispatch
+211
View File
@@ -0,0 +1,211 @@
"""Video-post drafts are CEO-gated artifacts, never delivery work; the video
authoring task is normal, dispatched work.
Mirrors test_self_heal_ceo_gate.py / test_x_dispatch_skip.py across the three
held-source skip sites: ``_is_held_ceo_source`` (used by ``_dispatch_pm_
work``), ``_dispatch_dev_work``'s inline skip chain, and ``TaskService.
list_pending_for_agent``'s SQL-level gate. A ``video_post`` task must never
reach any of the three; ``video`` (the UX/UI authoring task) must reach all of
them exactly like any other pre-assigned code task.
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.foundation import identity as _foundation
from roboco.runtime.orchestrator import AgentOrchestrator, _is_held_ceo_source
from roboco.services.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, TaskService
from sqlalchemy.dialects import postgresql
UX_DEV_UUID = _foundation.AGENTS["ux-dev-1"].uuid
def _task(tid: str, source: str, *, assigned_to: str | None = None) -> dict[str, Any]:
return {"id": tid, "source": source, "assigned_to": assigned_to}
def _bind(svc: object, name: str, value: object) -> None:
"""Stub `name` on `svc` without tripping mypy's method-assign check."""
object.__setattr__(svc, name, value)
# ---------------------------------------------------------------------------
# _is_held_ceo_source: the direct predicate
# ---------------------------------------------------------------------------
def test_is_held_ceo_source_true_for_video_post() -> None:
assert _is_held_ceo_source({"source": VIDEO_POST_SOURCE}) is True
def test_is_held_ceo_source_false_for_video_authoring() -> None:
assert _is_held_ceo_source({"source": VIDEO_SOURCE}) is False
# ---------------------------------------------------------------------------
# _dispatch_pm_work: a video_post draft is never routed as PM delivery work
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_video_post_never_routed_by_pm_dispatch() -> None:
tasks = [
_task("A", VIDEO_POST_SOURCE, assigned_to="secretary-1"),
_task("B", VIDEO_SOURCE, assigned_to="ux-dev-1"),
_task("C", "manual"), # ordinary unassigned -> routing still happens
]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="ux-dev-1")
stub._BOARD_AGENTS = frozenset()
stub._route_unassigned_pm_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
handled = [c.args[0]["id"] for c in stub._handle_pm_assigned_task.await_args_list]
assert handled == ["B"] # the authoring task dispatches; the held draft doesn't
stub._handle_board_assigned_task.assert_not_awaited()
routed = [c.args[1]["id"] for c in stub._route_unassigned_pm_task.await_args_list]
assert routed == ["C"]
# ---------------------------------------------------------------------------
# _dispatch_dev_work: a video_post draft is never routed as dev work
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_video_post_never_routed_by_dev_dispatch() -> None:
tasks = [
_task("A", VIDEO_POST_SOURCE, assigned_to="secretary-1"),
_task("B", VIDEO_SOURCE, assigned_to="ux-dev-1"),
]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
handled = [c.args[1]["id"] for c in stub._dev_dispatch_one.await_args_list]
assert handled == ["B"] # only the authoring task reaches dev dispatch
# ---------------------------------------------------------------------------
# list_pending_for_agent: give_me_work never offers a held video_post task
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_pending_for_agent_excludes_held_video_post() -> None:
"""Asserted at the SQL layer, mirroring the self-heal regression guard: the
query scopes the hold to video_post (among others), so the database drops
a held video-post draft before the agent ever sees the list."""
session = MagicMock()
result = MagicMock()
result.scalars.return_value.all.return_value = []
session.execute = AsyncMock(return_value=result)
svc = TaskService(session)
_bind(svc, "unmet_dependency_ids", AsyncMock(return_value=[]))
await svc.list_pending_for_agent(UX_DEV_UUID)
stmt = session.execute.await_args.args[0]
compiled = str(
stmt.compile(
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
)
)
assert VIDEO_POST_SOURCE in compiled
assert "confirmed_by_human" in compiled
@pytest.mark.asyncio
async def test_list_pending_for_agent_still_offers_video_authoring_task() -> None:
"""Regression guard: the hold is scoped to VIDEO_POST_SOURCE, not the
video-authoring source. A pre-assigned, confirmed video task (the normal
shape VideoEngine.open_video_task creates) must still be offered."""
session = MagicMock()
authoring = MagicMock()
authoring.source = VIDEO_SOURCE
authoring.confirmed_by_human = True
authoring.dependency_ids = []
result = MagicMock()
result.scalars.return_value.all.return_value = [authoring]
session.execute = AsyncMock(return_value=result)
svc = TaskService(session)
_bind(svc, "unmet_dependency_ids", AsyncMock(return_value=[]))
available = await svc.list_pending_for_agent(UX_DEV_UUID)
assert authoring in available
# ---------------------------------------------------------------------------
# _check_dev_needs_subtasks: the authoring task must dispatch, not deadlock
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_video_authoring_shape_passes_dev_subtask_guard() -> None:
"""A video authoring task is a root task assigned to a dev, so it must be
LOW complexity to clear _check_dev_needs_subtasks. A medium/high root dev
task is auto-blocked for subtasks it will never own, and unblock only loops
it back to a re-block a permanent deadlock. Guards the shape
VideoEngine.open_video_task emits."""
stub = MagicMock()
stub._auto_block_task = AsyncMock()
task = {
"id": "vid-low",
"source": VIDEO_SOURCE,
"assigned_to": "ux-dev-1",
"estimated_complexity": "low",
"parent_task_id": None,
}
client: Any = MagicMock()
result = await AgentOrchestrator._check_dev_needs_subtasks(
cast("AgentOrchestrator", stub), client, task
)
assert result is None
stub._auto_block_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_medium_root_dev_task_is_blocked() -> None:
"""Documents why the authoring task must stay LOW: a medium root task with
no subtasks assigned to a dev IS blocked here the exact trap a regression
back to medium/high complexity would spring."""
stub = MagicMock()
stub._auto_block_task = AsyncMock()
stub._api_url = "http://orchestrator"
resp = MagicMock()
resp.is_success = True
resp.json.return_value = []
client: Any = MagicMock()
client.get = AsyncMock(return_value=resp)
task = {
"id": "vid-med",
"source": VIDEO_SOURCE,
"assigned_to": "ux-dev-1",
"estimated_complexity": "medium",
"parent_task_id": None,
}
result = await AgentOrchestrator._check_dev_needs_subtasks(
cast("AgentOrchestrator", stub), client, task
)
assert result is not None
stub._auto_block_task.assert_awaited_once()
if __name__ == "__main__":
pytest.main([__file__, "-q"])
@@ -0,0 +1,407 @@
"""The orchestrator video-render loop: dormant when off; the cycle wrapper
iterates + commits (mocked wiring test, mirrors test_dep_update_loop.py); the
per-task render (mocked renderer/workspace, real DB) renders both cuts, holds
one video_post draft, and is idempotent (a rendered task is never re-rendered;
a failed render bounded-retries up to a cap, then is terminal) never itself
committing, so it never pollutes the session-scoped
shared test database the way routing it through the committing cycle wrapper
would.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import AsyncMock, MagicMock, call, patch
import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import AgentRole, AgentStatus, Team
from roboco.models.base import TaskStatus as TS
from roboco.runtime.orchestrator import (
_MAX_VIDEO_RENDER_ATTEMPTS,
AgentOrchestrator,
)
from roboco.services.task import VIDEO_POST_SOURCE, get_task_service
from roboco.services.video_engine import VideoEngine
from sqlalchemy import select
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
UX_DEV_1_UUID = _foundation.AGENTS["ux-dev-1"].uuid
UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
SLUG = "roboco"
ONE = 1
TWO = 2
def _orch() -> Any:
return AgentOrchestrator.__new__(AgentOrchestrator)
class _FakeRenderer:
"""Records every render() call; returns a deterministic path or raises."""
def __init__(self, *, fail: bool = False) -> None:
self.calls: list[dict[str, str]] = []
self.fail = fail
async def render(
self,
*,
source_dir: str,
composition_id: str,
input_props: dict[str, Any],
orientation: str,
render_key: str,
) -> str:
_ = input_props
self.calls.append(
{
"source_dir": source_dir,
"composition_id": composition_id,
"orientation": orientation,
"render_key": render_key,
}
)
if self.fail:
raise RuntimeError("render blew up")
return f"/fake-out/{composition_id}-{orientation}.mp4"
def _db_ctx(db: Any) -> Any:
@asynccontextmanager
async def _ctx() -> Any:
yield db
return _ctx
async def _seed(session: AsyncSession) -> None:
for uuid, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(SECRETARY_UUID, "secretary-1", AgentRole.SECRETARY, None),
(UX_DEV_1_UUID, "ux-dev-1", AgentRole.DEVELOPER, Team.UX_UI),
(UX_DEV_2_UUID, "ux-dev-2", AgentRole.DEVELOPER, Team.UX_UI),
):
if await session.get(AgentTable, uuid) is None:
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
existing = await session.execute(
select(ProjectTable).where(ProjectTable.slug == SLUG)
)
if existing.scalar_one_or_none() is None:
session.add(
ProjectTable(
name="RoboCo",
slug=SLUG,
git_url="https://github.com/x/roboco.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
)
)
await session.flush()
def _enable(monkeypatch: pytest.MonkeyPatch, **overrides: object) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
monkeypatch.setattr(cfg, "video_max_open_posts", 5)
monkeypatch.setattr(cfg, "video_render_interval_seconds", 120.0)
for key, value in overrides.items():
monkeypatch.setattr(cfg, key, value)
async def _make_completed_video_task(
session: AsyncSession, *, occasion: str, composition_id: str | None
) -> Any:
engine = VideoEngine(session)
task = await engine.open_video_task(
occasion=occasion,
script="Here's what shipped",
platforms=["x", "tiktok"],
brief="Announce the release",
)
assert task is not None
if composition_id is not None:
draft = markers.get_video_draft(task) or {}
markers.set_video_draft(
task,
{
**draft,
"composition_id": composition_id,
"input_props": {"title": "hello"},
"x_caption": "Check out our new release!",
"tiktok_caption": "New release, check it out",
"platforms": ["x", "tiktok"],
},
)
task.status = TS.COMPLETED
await session.flush()
return task
def _render_patches(renderer: _FakeRenderer, workspace: Any) -> Any:
return (
patch(
"roboco.services.remotion_client.get_remotion_renderer",
lambda: renderer,
),
patch(
"roboco.services.workspace.get_workspace_service",
lambda _db: workspace,
),
)
def _fake_workspace() -> Any:
return SimpleNamespace(
ensure_read_clone=AsyncMock(return_value=Path("/fake-clone"))
)
# --------------------------------------------------------------------------- #
# _video_render_loop dormancy
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_loop_returns_immediately_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", False)
stub = cast("AgentOrchestrator", SimpleNamespace(_running=True))
await asyncio.wait_for(AgentOrchestrator._video_render_loop(stub), timeout=1.0)
# --------------------------------------------------------------------------- #
# _run_video_render_cycle — wiring only (mocked db/service, mirrors
# test_dep_update_loop.py); the substantive render behavior is covered below
# against _render_video_task directly, which never commits.
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_run_cycle_processes_each_completed_task_and_commits() -> None:
orch = _orch()
task_a = MagicMock()
task_b = MagicMock()
db = MagicMock()
db.commit = AsyncMock()
task_svc = MagicMock()
task_svc.list_completed_video_tasks = AsyncMock(return_value=[task_a, task_b])
orch._render_video_task = AsyncMock()
with (
patch("roboco.db.get_db_context", _db_ctx(db)),
patch("roboco.services.task.get_task_service", return_value=task_svc),
):
await orch._run_video_render_cycle()
assert orch._render_video_task.await_args_list == [
call(db, task_a),
call(db, task_b),
]
db.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_cycle_with_no_completed_tasks_still_commits() -> None:
orch = _orch()
db = MagicMock()
db.commit = AsyncMock()
task_svc = MagicMock()
task_svc.list_completed_video_tasks = AsyncMock(return_value=[])
orch._render_video_task = AsyncMock()
with (
patch("roboco.db.get_db_context", _db_ctx(db)),
patch("roboco.services.task.get_task_service", return_value=task_svc),
):
await orch._run_video_render_cycle()
orch._render_video_task.assert_not_awaited()
db.commit.assert_awaited_once()
# --------------------------------------------------------------------------- #
# _render_video_task — real DB (flush only, never commits: the session-scoped
# shared test DB stays clean via this test's own rollback teardown), mocked
# renderer + workspace clone.
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_render_video_task_renders_both_cuts_and_materializes_post(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="render-both-cuts", composition_id="Intro"
)
renderer = _FakeRenderer()
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with p1, p2:
await orch._render_video_task(db_session, task)
workspace.ensure_read_clone.assert_awaited_once_with(SLUG)
assert len(renderer.calls) == TWO
orientations = {c["orientation"] for c in renderer.calls}
assert orientations == {"vertical", "square"}
expected_motion_dir = str(Path("/fake-clone") / "motion")
assert all(c["source_dir"] == expected_motion_dir for c in renderer.calls)
assert all(c["render_key"] == str(task.id) for c in renderer.calls) # task-scoped
posts = await get_task_service(db_session).list_open_video_posts()
assert len(posts) == ONE
assert posts[0].source == VIDEO_POST_SOURCE
draft = markers.get_video_draft(posts[0])
assert draft is not None
assert draft["mp4_paths"] == {
"vertical": "/fake-out/Intro-vertical.mp4",
"square": "/fake-out/Intro-square.mp4",
}
assert draft["x_caption"] == "Check out our new release!"
assert draft["tiktok_caption"] == "New release, check it out"
source_draft = markers.get_video_draft(task)
assert source_draft is not None
assert source_draft["render_status"] == "rendered"
@pytest.mark.asyncio
async def test_render_video_task_second_call_is_idempotent(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="idempotent", composition_id="Intro"
)
renderer = _FakeRenderer()
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with p1, p2:
await orch._render_video_task(db_session, task)
await orch._render_video_task(db_session, task) # must be a no-op
assert len(renderer.calls) == TWO # not four — the second call skipped it
posts = await get_task_service(db_session).list_open_video_posts()
assert len(posts) == ONE
@pytest.mark.asyncio
async def test_render_video_task_skips_task_without_composition_id(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="no-composition", composition_id=None
)
renderer = _FakeRenderer()
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with p1, p2:
await orch._render_video_task(db_session, task)
assert renderer.calls == []
workspace.ensure_read_clone.assert_not_awaited()
posts = await get_task_service(db_session).list_open_video_posts()
assert posts == []
draft = markers.get_video_draft(task)
assert draft is not None
assert draft.get("render_status") is None
@pytest.mark.asyncio
async def test_render_video_task_single_failure_retries_not_terminal(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""One failure bumps the attempt counter but does NOT terminally fail the
task a stale read-clone or a transient sidecar blip must be retried on a
later cycle, not silently lost."""
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="render-fails-once", composition_id="Intro"
)
renderer = _FakeRenderer(fail=True)
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with p1, p2:
await orch._render_video_task(db_session, task)
posts = await get_task_service(db_session).list_open_video_posts()
assert posts == []
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["render_attempts"] == ONE
assert draft.get("render_status") is None # retried next cycle, not terminal
@pytest.mark.asyncio
async def test_render_video_task_terminal_after_max_attempts(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""After _MAX_VIDEO_RENDER_ATTEMPTS failures the task is terminally failed
and never rendered again a genuinely broken composition can't loop."""
await _seed(db_session)
_enable(monkeypatch)
task = await _make_completed_video_task(
db_session, occasion="max-attempts", composition_id="Intro"
)
seeded = markers.get_video_draft(task) or {}
markers.set_video_draft(
task, {**seeded, "render_attempts": _MAX_VIDEO_RENDER_ATTEMPTS - 1}
)
await db_session.flush()
renderer = _FakeRenderer(fail=True)
workspace = _fake_workspace()
orch = _orch()
p1, p2 = _render_patches(renderer, workspace)
with p1, p2:
await orch._render_video_task(db_session, task) # tips to terminal
calls_at_terminal = len(renderer.calls)
await orch._render_video_task(db_session, task) # now a no-op
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["render_attempts"] == _MAX_VIDEO_RENDER_ATTEMPTS
assert draft["render_status"] == "failed"
assert len(renderer.calls) == calls_at_terminal # not retried after terminal
posts = await get_task_service(db_session).list_open_video_posts()
assert posts == []
+222
View File
@@ -0,0 +1,222 @@
"""HeartbeatMutex coverage: acquire (fencing token), heartbeat renew,
compare-and-del release, fail-closed on a Redis outage, and the
run_guarded cancel-on-lock-loss dance.
No live Redis in tests (matches the project's `_no_live_redis` fixture); a
tiny in-memory fake backs the Lua compare-and-del/compare-and-expire scripts
so the fencing semantics are observable without a real Redis.
"""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.services.heartbeat_mutex import (
HeartbeatLockUnavailable,
HeartbeatMutex,
)
_KEY = "roboco:test_mutex:abc"
_MIN_RENEWS_AFTER_RECOVERY = 2 # the failed renew, then >=1 recovered one
class _FakeRedis:
"""In-memory single-key store backing the mutex's SET NX EX + two Lua
scripts (compare-and-del release, compare-and-expire heartbeat)."""
def __init__(self) -> None:
self._store: dict[str, str] = {}
self.set_calls: list[tuple[str, str, bool, int]] = []
self.eval_calls: list[tuple[str, tuple[Any, ...]]] = []
async def set(
self, name: str, value: str, *, nx: bool = False, ex: int = 0
) -> bool:
self.set_calls.append((name, value, nx, ex))
if nx and name in self._store:
return False
self._store[name] = value
return True
async def eval(self, script: str, _numkeys: int, *args: Any) -> int:
self.eval_calls.append((script, args))
key, token = args[0], args[1]
if "expire" in script:
return 1 if self._store.get(key) == token else 0
if self._store.get(key) == token:
del self._store[key]
return 1
return 0
async def aclose(self) -> None:
return None
def _mutex(*, ttl: int = 60, heartbeat: float = 30.0) -> HeartbeatMutex:
return HeartbeatMutex(_KEY, ttl_seconds=ttl, heartbeat_seconds=heartbeat)
@pytest.mark.asyncio
async def test_acquire_sets_nx_ex_and_returns_a_fencing_token() -> None:
fake = _FakeRedis()
with patch("roboco.services.heartbeat_mutex.redis.from_url", return_value=fake):
token = await _mutex(ttl=1800).acquire()
assert token is not None
assert fake.set_calls == [(_KEY, token, True, 1800)]
@pytest.mark.asyncio
async def test_acquire_returns_none_when_already_held() -> None:
fake = _FakeRedis()
with patch("roboco.services.heartbeat_mutex.redis.from_url", return_value=fake):
first = await _mutex().acquire()
second = await _mutex().acquire()
assert first is not None
assert second is None
@pytest.mark.asyncio
async def test_release_is_compare_and_del_spares_a_usurper_lock() -> None:
fake = _FakeRedis()
with patch("roboco.services.heartbeat_mutex.redis.from_url", return_value=fake):
mutex = _mutex()
token = await mutex.acquire()
assert token is not None
# A usurper re-acquired after this token's TTL expired.
fake._store[_KEY] = "usurper-token"
await mutex.release(token)
assert fake._store.get(_KEY) == "usurper-token" # survives a stale release
await mutex.release("usurper-token")
assert _KEY not in fake._store # the owning token does clear it
@pytest.mark.asyncio
async def test_heartbeat_once_true_when_owned_false_otherwise() -> None:
fake = _FakeRedis()
with patch("roboco.services.heartbeat_mutex.redis.from_url", return_value=fake):
mutex = _mutex()
token = await mutex.acquire()
assert token is not None
assert await mutex.heartbeat_once(token) is True
assert await mutex.heartbeat_once("wrong-token") is False
@pytest.mark.asyncio
async def test_acquire_raises_lock_unavailable_on_redis_error() -> None:
broken = MagicMock()
broken.set = AsyncMock(side_effect=ConnectionError("redis down"))
broken.aclose = AsyncMock()
with (
patch("roboco.services.heartbeat_mutex.redis.from_url", return_value=broken),
pytest.raises(HeartbeatLockUnavailable),
):
await _mutex().acquire()
@pytest.mark.asyncio
async def test_run_guarded_returns_the_coroutine_result_on_success() -> None:
async def _work() -> str:
return "done"
mutex = _mutex(heartbeat=0.001)
with patch.object(HeartbeatMutex, "heartbeat_once", AsyncMock(return_value=True)):
result = await mutex.run_guarded(_work(), "tok")
assert result.lock_lost is False
assert result.value == "done"
@pytest.mark.asyncio
async def test_run_guarded_renews_the_ttl_while_the_work_runs() -> None:
calls = 0
async def _counting_heartbeat(_self: HeartbeatMutex, _token: str) -> bool:
nonlocal calls
calls += 1
return True
async def _slow_work() -> str:
await asyncio.sleep(0.02)
return "done"
mutex = _mutex(heartbeat=0.001)
with patch.object(HeartbeatMutex, "heartbeat_once", _counting_heartbeat):
result = await mutex.run_guarded(_slow_work(), "tok")
assert result.value == "done"
assert calls >= 1 # at least one renew landed while the work was in flight
@pytest.mark.asyncio
async def test_run_guarded_cancels_the_work_fail_closed_on_lock_loss() -> None:
started = asyncio.Event()
async def _blocking_work() -> str:
started.set()
await asyncio.sleep(60)
return "never"
mutex = _mutex(heartbeat=0.001)
with patch.object(HeartbeatMutex, "heartbeat_once", AsyncMock(return_value=False)):
result = await mutex.run_guarded(_blocking_work(), "tok")
assert started.is_set() # the work did start, then got cancelled
assert result.lock_lost is True
assert result.value is None
@pytest.mark.asyncio
async def test_run_guarded_tolerates_a_transient_renew_raise() -> None:
"""One raised renew error, followed by recovery, must NOT trip
lock_lost the TTL is still alive so it's tolerated as a blip."""
calls = 0
async def _flaky_heartbeat(_self: HeartbeatMutex, _token: str) -> bool:
nonlocal calls
calls += 1
if calls == 1:
raise ConnectionError("transient redis blip")
return True
async def _work() -> str:
await asyncio.sleep(0.02)
return "done"
# ttl=60 vs. a sub-second test run: the grace window is enormous, so a
# single raise is nowhere near "unable to renew for ~the whole TTL".
mutex = _mutex(ttl=60, heartbeat=0.005)
with patch.object(HeartbeatMutex, "heartbeat_once", _flaky_heartbeat):
result = await mutex.run_guarded(_work(), "tok")
assert result.lock_lost is False
assert result.value == "done"
assert calls >= _MIN_RENEWS_AFTER_RECOVERY
@pytest.mark.asyncio
async def test_run_guarded_fails_closed_once_renew_errors_span_the_whole_ttl() -> None:
"""A `heartbeat_once` that only ever RAISES (never returns falsy) must
still fail closed once elapsed time since the last successful renew
reaches ~the whole TTL otherwise a holder stuck erroring on every
renew would never learn its key expired server-side.
`heartbeat_seconds > ttl_seconds` collapses the grace window
(`ttl_seconds - heartbeat_seconds`) to <= 0, so the very first raise's
elapsed time (always >= 0) already exceeds it deterministic, no
reliance on real elapsed wall-clock time (and no monkeypatching
`time.monotonic`, which is also asyncio's own scheduling clock).
"""
started = asyncio.Event()
async def _blocking_work() -> str:
started.set()
await asyncio.sleep(60)
return "never"
mock_heartbeat = AsyncMock(side_effect=ConnectionError("redis down"))
mutex = _mutex(ttl=1, heartbeat=2.0)
with patch.object(HeartbeatMutex, "heartbeat_once", mock_heartbeat):
result = await mutex.run_guarded(_blocking_work(), "tok")
assert mock_heartbeat.call_count >= 1
assert started.is_set()
assert result.lock_lost is True
assert result.value is None
@@ -0,0 +1,200 @@
"""The release-proposal publish hook drafts a video-authoring task
(best-effort, never raises into approve()). Layering: release_proposal calls
only the small typed seam ``VideoEngine.draft_release_video`` this test
patches at that seam, not the engine's internals. Mirrors
test_release_proposal_x_hook.py for the sibling X-post hook."""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.foundation import identity as _foundation
from roboco.models.base import AgentRole, AgentStatus, TaskNature, TaskStatus, TaskType
from roboco.models.base import Team as T
from roboco.services.release_executor import ReleaseResult
from roboco.services.release_proposal import ReleaseProposalService
from roboco.services.release_readiness import ReleaseReadinessReport, report_to_dict
from roboco.services.task import RELEASE_MANAGER_SOURCE
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
_VERSION = "0.18.0"
_CHANGELOG = f"## [{_VERSION}]\n\n### Added\n- a thing\n"
def _report() -> ReleaseReadinessReport:
return ReleaseReadinessReport(
proposed_version=_VERSION,
bump_kind="minor",
change_summary=["feat: a thing", "fix: another thing"],
drafted_changelog=_CHANGELOG,
version_bump_plan=["pyproject.toml"],
gaps=[],
migration_notes=[],
gate_state="green",
)
async def _seed_proposal(session: AsyncSession) -> TaskTable:
system_uuid = _foundation.AGENTS["system"].uuid
secretary_uuid = _foundation.AGENTS["secretary-1"].uuid
for uuid_, slug, role in (
(system_uuid, "system", AgentRole.SYSTEM),
(secretary_uuid, "secretary-1", AgentRole.SECRETARY),
):
if await session.get(AgentTable, uuid_) is None:
session.add(
AgentTable(
id=uuid_,
name=slug,
slug=slug,
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
project = ProjectTable(
id=uuid4(),
name="RoboCo",
slug=f"roboco-{uuid4().hex[:6]}",
git_url="https://example.com/roboco.git",
assigned_cell=T.BACKEND,
created_by=system_uuid,
)
session.add(project)
await session.flush()
task = TaskTable(
id=uuid4(),
title=f"Release proposal: v{_VERSION}",
description="proposal body",
acceptance_criteria=["CEO approves"],
status=TaskStatus.PENDING,
priority=2,
task_type=TaskType.ADMINISTRATIVE,
nature=TaskNature.NON_TECHNICAL,
project_id=project.id,
created_by=system_uuid,
assigned_to=secretary_uuid,
team=T.MAIN_PM,
source=RELEASE_MANAGER_SOURCE,
confirmed_by_human=False,
orchestration_markers={"release_report": report_to_dict(_report())},
)
session.add(task)
await session.flush()
return task
@pytest.mark.asyncio
async def test_publish_success_calls_video_engine_draft_seam(
db_session: AsyncSession,
) -> None:
task = await _seed_proposal(db_session)
published = ReleaseResult(
status="published",
version=_VERSION,
files_changed=["pyproject.toml"],
commit_sha="abc123",
release_url=f"https://github.com/x/roboco/releases/tag/v{_VERSION}",
detail="ok",
)
fake_executor = AsyncMock()
fake_executor.execute = AsyncMock(return_value=published)
fake_x_engine = AsyncMock()
fake_x_engine.draft_release_post = AsyncMock(return_value=None)
fake_video_engine = AsyncMock()
fake_video_engine.draft_release_video = AsyncMock(return_value=None)
with (
patch(
"roboco.services.release_proposal.get_release_executor",
AsyncMock(return_value=fake_executor),
),
patch("roboco.services.x_engine.get_x_engine", return_value=fake_x_engine),
patch(
"roboco.services.video_engine.get_video_engine",
return_value=fake_video_engine,
),
patch.object(
ReleaseProposalService, "_acquire_release_lock", AsyncMock(return_value="t")
),
patch.object(
ReleaseProposalService,
"_release_release_lock",
AsyncMock(return_value=None),
),
patch.object(
ReleaseProposalService,
"_heartbeat_release_lock",
AsyncMock(return_value=True),
),
):
result = await ReleaseProposalService(db_session).approve(cast("UUID", task.id))
assert result is not None
assert result.status == "published"
fake_video_engine.draft_release_video.assert_awaited_once_with(
version=_VERSION, changelog=_CHANGELOG
)
@pytest.mark.asyncio
async def test_video_draft_failure_never_fails_the_approve(
db_session: AsyncSession,
) -> None:
"""A drafting exception is swallowed — the release already published."""
task = await _seed_proposal(db_session)
published = ReleaseResult(
status="published",
version=_VERSION,
files_changed=["pyproject.toml"],
commit_sha="abc123",
release_url=None,
detail="ok",
)
fake_executor = AsyncMock()
fake_executor.execute = AsyncMock(return_value=published)
with (
patch(
"roboco.services.release_proposal.get_release_executor",
AsyncMock(return_value=fake_executor),
),
patch("roboco.services.x_engine.get_x_engine", return_value=AsyncMock()),
patch(
"roboco.services.video_engine.get_video_engine",
side_effect=RuntimeError("video-engine boom"),
),
patch.object(
ReleaseProposalService, "_acquire_release_lock", AsyncMock(return_value="t")
),
patch.object(
ReleaseProposalService,
"_release_release_lock",
AsyncMock(return_value=None),
),
patch.object(
ReleaseProposalService,
"_heartbeat_release_lock",
AsyncMock(return_value=True),
),
):
result = await ReleaseProposalService(db_session).approve(cast("UUID", task.id))
assert result is not None
assert result.status == "published"
await db_session.refresh(task)
assert task.status == TaskStatus.COMPLETED # the release itself still completed
+168
View File
@@ -0,0 +1,168 @@
"""RemotionRenderer coverage: tar/post/save happy path against a mocked httpx
transport, plus unconfigured/unreachable graceful failure (never a crash).
"""
from __future__ import annotations
from pathlib import Path
import httpx
import pytest
from roboco.config import settings as cfg
from roboco.services.remotion_client import (
NullRemotionRenderer,
RemotionRenderer,
RemotionRendererError,
get_remotion_renderer,
)
def _make_source(tmp_path: Path) -> Path:
source = tmp_path / "motion"
source.mkdir()
(source / "Intro.tsx").write_text("export const Intro = () => null;")
return source
@pytest.mark.asyncio
async def test_render_posts_tar_and_saves_mp4(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_source(tmp_path)
out_dir = tmp_path / "out"
monkeypatch.setattr(cfg, "video_output_dir", str(out_dir))
monkeypatch.setattr(cfg, "video_request_timeout_seconds", 5.0)
monkeypatch.setattr(cfg, "video_render_timeout_seconds", 30.0)
captured: dict[str, bytes | str] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["content_type"] = request.headers["content-type"]
captured["body"] = request.content
return httpx.Response(200, content=b"fake-mp4-bytes")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
renderer = RemotionRenderer(base_url="http://fake-remotion", client=http_client)
path = await renderer.render(
source_dir=str(source),
composition_id="Intro",
input_props={"title": "hello"},
orientation="vertical",
render_key="task-99",
)
await http_client.aclose()
assert captured["url"] == "http://fake-remotion/render"
assert str(captured["content_type"]).startswith("multipart/form-data")
body = captured["body"]
assert isinstance(body, bytes)
assert b"composition_id" in body
assert b"Intro" in body
assert b"vertical" in body
assert b"motion.tar.gz" in body
assert b"\x1f\x8b" in body # gzip magic bytes: the tar payload made it in
saved = Path(path)
assert saved.exists()
assert saved.read_bytes() == b"fake-mp4-bytes"
assert saved.parent == out_dir
assert saved.name == "task-99-vertical.mp4" # task-scoped, not composition-scoped
@pytest.mark.asyncio
async def test_render_non_success_response_raises_clear_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_source(tmp_path)
monkeypatch.setattr(cfg, "video_output_dir", str(tmp_path / "out"))
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="render crashed")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
renderer = RemotionRenderer(base_url="http://fake-remotion", client=http_client)
with pytest.raises(RemotionRendererError, match="500"):
await renderer.render(
source_dir=str(source),
composition_id="Intro",
input_props={},
orientation="square",
render_key="t1",
)
await http_client.aclose()
@pytest.mark.asyncio
async def test_render_unreachable_sidecar_raises_clear_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_source(tmp_path)
monkeypatch.setattr(cfg, "video_output_dir", str(tmp_path / "out"))
def handler(_request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("connection refused")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
renderer = RemotionRenderer(base_url="http://fake-remotion", client=http_client)
with pytest.raises(RemotionRendererError, match="render request failed"):
await renderer.render(
source_dir=str(source),
composition_id="Intro",
input_props={},
orientation="square",
render_key="t1",
)
await http_client.aclose()
@pytest.mark.asyncio
async def test_unconfigured_renderer_raises_without_network_call(
tmp_path: Path,
) -> None:
source = _make_source(tmp_path)
renderer = RemotionRenderer(base_url="")
with pytest.raises(RemotionRendererError, match="not configured"):
await renderer.render(
source_dir=str(source),
composition_id="Intro",
input_props={},
orientation="vertical",
render_key="t1",
)
@pytest.mark.asyncio
async def test_null_renderer_raises_without_network_call(tmp_path: Path) -> None:
source = _make_source(tmp_path)
renderer = NullRemotionRenderer()
with pytest.raises(RemotionRendererError, match="not configured"):
await renderer.render(
source_dir=str(source),
composition_id="Intro",
input_props={},
orientation="vertical",
render_key="t1",
)
def test_get_remotion_renderer_returns_null_when_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "remotion_base_url", "")
renderer = get_remotion_renderer()
assert isinstance(renderer, NullRemotionRenderer)
def test_get_remotion_renderer_returns_real_client_when_set(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "remotion_base_url", "http://roboco-remotion:3001")
renderer = get_remotion_renderer()
assert isinstance(renderer, RemotionRenderer)
assert not isinstance(renderer, NullRemotionRenderer)
+319
View File
@@ -0,0 +1,319 @@
"""LiveTikTokPoster coverage: the inbox-upload sequence (init -> chunked PUT
-> status poll), the asymmetric final chunk via `plan_chunk_ranges`, and the
OAuth2 refresh-token rotation + persistence path.
Uses the real `db_session` fixture (mirrors test_video_post_service.py) since
the refresh path genuinely persists to the `tiktok_credentials` row a mock
session can't stand in for that.
"""
from __future__ import annotations
import json
from itertools import pairwise
from typing import TYPE_CHECKING
import httpx
import pytest
from roboco.services.tiktok_client import (
LiveTikTokPoster,
build_tiktok_poster,
plan_chunk_ranges,
)
from roboco.services.tiktok_credentials import (
TikTokCredentialsData,
get_tiktok_credentials_service,
)
from roboco.services.video_post_service import NullTikTokPoster
if TYPE_CHECKING:
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
TWO = 2
FOUR = 4
_CREDS = TikTokCredentialsData(
client_key="ck-test",
client_secret="cs-test",
access_token="at-test",
refresh_token="rt-test",
)
# ---- plan_chunk_ranges (pure) ---------------------------------------------- #
def test_plan_chunk_ranges_single_chunk_when_within_final_ceiling() -> None:
assert plan_chunk_ranges(50, chunk_size=64, max_final_chunk=128) == [(0, 50)]
def test_plan_chunk_ranges_folds_the_remainder_into_a_larger_final_chunk() -> None:
"""Interior chunks stay a fixed size; the final one absorbs the
remainder larger than the interior size, the documented asymmetry."""
ranges = plan_chunk_ranges(10, chunk_size=4, max_final_chunk=8)
assert ranges == [(0, 4), (4, 10)]
final_start, final_end = ranges[-1]
assert (final_end - final_start) > FOUR # bigger than the interior chunk size
def test_plan_chunk_ranges_multiple_interior_chunks() -> None:
total = 300
ranges = plan_chunk_ranges(total, chunk_size=64, max_final_chunk=128)
assert ranges == [(0, 64), (64, 128), (128, 192), (192, 300)]
assert sum(end - start for start, end in ranges) == total
def test_plan_chunk_ranges_is_contiguous_with_no_gaps_or_overlaps() -> None:
total = 1000
ranges = plan_chunk_ranges(total, chunk_size=97, max_final_chunk=150)
assert ranges[0][0] == 0
assert ranges[-1][1] == total
for (_, prev_end), (next_start, _) in pairwise(ranges):
assert prev_end == next_start
def test_plan_chunk_ranges_empty_file() -> None:
assert plan_chunk_ranges(0, chunk_size=64, max_final_chunk=128) == [(0, 0)]
# ---- build_tiktok_poster branching ----------------------------------------- #
@pytest.mark.asyncio
async def test_build_without_creds_returns_null(db_session: AsyncSession) -> None:
poster = build_tiktok_poster(None, session=db_session, timeout=5.0)
assert isinstance(poster, NullTikTokPoster)
assert poster.configured is False
@pytest.mark.asyncio
async def test_build_with_creds_returns_live(db_session: AsyncSession) -> None:
poster = build_tiktok_poster(_CREDS, session=db_session, timeout=5.0)
assert isinstance(poster, LiveTikTokPoster)
assert poster.configured is True
@pytest.mark.asyncio
async def test_null_poster_upload_is_a_noop() -> None:
result = await NullTikTokPoster().upload_to_inbox(
mp4_path="/tmp/x.mp4", caption="hi"
)
assert result.uploaded is False
assert result.publish_id is None
def _write_clip(tmp_path: Path, data: bytes) -> str:
mp4 = tmp_path / "clip.mp4"
mp4.write_bytes(data)
return str(mp4)
async def _no_sleep(_seconds: float) -> None:
return None
# ---- upload_to_inbox: full sequence + asymmetric chunk over the wire ------- #
@pytest.mark.asyncio
async def test_upload_to_inbox_full_sequence_with_asymmetric_last_chunk(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, db_session: AsyncSession
) -> None:
monkeypatch.setattr("roboco.services.tiktok_client._CHUNK_SIZE_BYTES", 4)
monkeypatch.setattr("roboco.services.tiktok_client._MAX_FINAL_CHUNK_BYTES", 8)
monkeypatch.setattr("roboco.services.tiktok_client.asyncio.sleep", _no_sleep)
put_ranges: list[str] = []
status_calls = {"count": 0}
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if request.method == "POST" and path.endswith("/inbox/video/init/"):
assert request.headers["Authorization"] == "Bearer at-test"
body = json.loads(request.content)["source_info"]
assert body == {
"source": "FILE_UPLOAD",
"video_size": 10,
"chunk_size": 4,
"total_chunk_count": TWO,
}
return httpx.Response(
200,
json={
"data": {
"publish_id": "pub1",
"upload_url": "https://upload.tiktokapis.com/put/pub1",
}
},
)
if request.method == "PUT":
put_ranges.append(request.headers["Content-Range"])
assert request.headers["Content-Type"] == "video/mp4"
return httpx.Response(200)
if request.method == "POST" and path.endswith("/status/fetch/"):
status_calls["count"] += 1
state = (
"PROCESSING_UPLOAD"
if status_calls["count"] == 1
else "SEND_TO_USER_INBOX"
)
return httpx.Response(200, json={"data": {"status": state}})
raise AssertionError(f"unexpected request {request.method} {path}")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
poster = LiveTikTokPoster(
_CREDS, session=db_session, timeout=5.0, client=http_client
)
result = await poster.upload_to_inbox(
mp4_path=_write_clip(tmp_path, b"0123456789"), caption="ignored by inbox mode"
)
await http_client.aclose()
assert result.uploaded is True
assert result.publish_id == "pub1"
# Interior chunk is 4 bytes; the final chunk absorbs the remainder (6
# bytes) rather than being sent as its own small trailing chunk.
assert put_ranges == ["bytes 0-3/10", "bytes 4-9/10"]
assert status_calls["count"] == TWO # PROCESSING_UPLOAD once, then terminal
@pytest.mark.asyncio
async def test_upload_publish_failed_status_is_graceful(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, db_session: AsyncSession
) -> None:
monkeypatch.setattr("roboco.services.tiktok_client.asyncio.sleep", _no_sleep)
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path.endswith("/inbox/video/init/"):
return httpx.Response(
200, json={"data": {"publish_id": "pub1", "upload_url": "https://u/x"}}
)
if request.method == "PUT":
return httpx.Response(200)
if path.endswith("/status/fetch/"):
return httpx.Response(200, json={"data": {"status": "FAILED"}})
raise AssertionError(f"unexpected request {path}")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
poster = LiveTikTokPoster(
_CREDS, session=db_session, timeout=5.0, client=http_client
)
result = await poster.upload_to_inbox(
mp4_path=_write_clip(tmp_path, b"short"), caption="x"
)
await http_client.aclose()
assert result.uploaded is False
assert result.publish_id is None
assert "publish failed" in result.detail
# ---- OAuth2 refresh: rotate + persist -------------------------------------- #
@pytest.mark.asyncio
async def test_upload_refreshes_and_persists_rotated_token_on_401(
tmp_path: Path, db_session: AsyncSession
) -> None:
"""A 401 on the Bearer-authed init call triggers one refresh + retry; the
rotated access/refresh token pair must land in the DB row not just be
held in the poster's in-memory creds."""
await get_tiktok_credentials_service(db_session).set_credentials(
client_key=_CREDS.client_key,
client_secret=_CREDS.client_secret,
access_token=_CREDS.access_token,
refresh_token=_CREDS.refresh_token,
)
init_auth_headers: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path.endswith("/oauth/token/"):
form = dict(httpx.QueryParams(request.content.decode()))
assert form["grant_type"] == "refresh_token"
assert form["refresh_token"] == "rt-test"
return httpx.Response(
200, json={"access_token": "at-rotated", "refresh_token": "rt-rotated"}
)
if path.endswith("/inbox/video/init/"):
init_auth_headers.append(request.headers["Authorization"])
if request.headers["Authorization"] == "Bearer at-test":
return httpx.Response(401, json={"error": "access_token_invalid"})
return httpx.Response(
200, json={"data": {"publish_id": "pub2", "upload_url": "https://u/x"}}
)
if request.method == "PUT":
return httpx.Response(200)
if path.endswith("/status/fetch/"):
return httpx.Response(200, json={"data": {"status": "SEND_TO_USER_INBOX"}})
raise AssertionError(f"unexpected request {path}")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
poster = LiveTikTokPoster(
_CREDS, session=db_session, timeout=5.0, client=http_client
)
result = await poster.upload_to_inbox(
mp4_path=_write_clip(tmp_path, b"short"), caption="x"
)
await http_client.aclose()
assert result.uploaded is True
assert init_auth_headers == ["Bearer at-test", "Bearer at-rotated"]
stored = await get_tiktok_credentials_service(db_session).get_decrypted()
assert stored is not None
assert stored.access_token == "at-rotated"
assert stored.refresh_token == "rt-rotated"
assert stored.client_key == _CREDS.client_key # untouched by the refresh
@pytest.mark.asyncio
async def test_refresh_falls_back_to_current_refresh_token_when_response_omits_it(
tmp_path: Path, db_session: AsyncSession
) -> None:
"""TikTok doesn't always rotate the refresh_token — when the grant
response omits it, the previous one must be kept, not nulled out."""
await get_tiktok_credentials_service(db_session).set_credentials(
client_key=_CREDS.client_key,
client_secret=_CREDS.client_secret,
access_token=_CREDS.access_token,
refresh_token=_CREDS.refresh_token,
)
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path.endswith("/oauth/token/"):
return httpx.Response(200, json={"access_token": "at-rotated"})
if path.endswith("/inbox/video/init/"):
if request.headers["Authorization"] == "Bearer at-test":
return httpx.Response(401, json={"error": "access_token_invalid"})
return httpx.Response(
200, json={"data": {"publish_id": "pub3", "upload_url": "https://u/x"}}
)
if request.method == "PUT":
return httpx.Response(200)
if path.endswith("/status/fetch/"):
return httpx.Response(200, json={"data": {"status": "SEND_TO_USER_INBOX"}})
raise AssertionError(f"unexpected request {path}")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
poster = LiveTikTokPoster(
_CREDS, session=db_session, timeout=5.0, client=http_client
)
result = await poster.upload_to_inbox(
mp4_path=_write_clip(tmp_path, b"x"), caption="x"
)
await http_client.aclose()
assert result.uploaded is True
stored = await get_tiktok_credentials_service(db_session).get_decrypted()
assert stored is not None
assert stored.access_token == "at-rotated"
assert stored.refresh_token == _CREDS.refresh_token # unchanged, fallback
+462
View File
@@ -0,0 +1,462 @@
"""VideoEngine coverage: authoring-task origination + held-draft materialization.
Mirrors the X-engine tests: flag-gated, dedup, rolling open-cap, and a
deterministic ux-dev balance. The authoring task (source=video) is a normal
ASSIGNED delivery task never held; the post draft (source=video_post) is
Secretary-owned and held for the CEO. Asserted against a real Postgres DB.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock
import pytest
from roboco.config import settings as cfg
from roboco.db.tables import AgentTable, ProjectTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import AgentRole, AgentStatus, Complexity, Team
from roboco.models.base import TaskStatus as TS
from roboco.services import video_engine as video_engine_module
from roboco.services.task import VIDEO_POST_SOURCE, VIDEO_SOURCE, get_task_service
from sqlalchemy import delete, select
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
UX_DEV_1_UUID = _foundation.AGENTS["ux-dev-1"].uuid
UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
SLUG = "roboco"
ONE = 1
TWO = 2
async def _seed(session: AsyncSession) -> None:
for uuid, slug, role, team in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(SECRETARY_UUID, "secretary-1", AgentRole.SECRETARY, None),
(UX_DEV_1_UUID, "ux-dev-1", AgentRole.DEVELOPER, Team.UX_UI),
(UX_DEV_2_UUID, "ux-dev-2", AgentRole.DEVELOPER, Team.UX_UI),
):
if await session.get(AgentTable, uuid) is None:
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
existing = await session.execute(
select(ProjectTable).where(ProjectTable.slug == SLUG)
)
if existing.scalar_one_or_none() is None:
session.add(
ProjectTable(
name="RoboCo",
slug=SLUG,
git_url="https://github.com/x/roboco.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
)
)
await session.flush()
def _enable(monkeypatch: pytest.MonkeyPatch, **overrides: object) -> None:
monkeypatch.setattr(cfg, "video_engine_enabled", True)
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
monkeypatch.setattr(cfg, "video_max_open_posts", 5)
for key, value in overrides.items():
monkeypatch.setattr(cfg, key, value)
def _mock_local_model(monkeypatch: pytest.MonkeyPatch, reply: str | None) -> AsyncMock:
mock = AsyncMock(return_value=reply)
monkeypatch.setattr(video_engine_module, "_chat", mock)
return mock
# --------------------------------------------------------------------------- #
# open_video_task
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_disabled_opens_no_video_task(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
monkeypatch.setattr(cfg, "video_engine_enabled", False)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="release v1.0.0", script="script", platforms=["x"], brief="brief"
)
assert task is None
assert await get_task_service(db_session).list_open_video_posts() == []
@pytest.mark.asyncio
async def test_open_video_task_creates_assigned_authoring_task(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="release v1.0.0",
script="Here's what shipped",
platforms=["x", "tiktok"],
brief="Announce the release",
)
assert task is not None
assert task.team == Team.UX_UI
assert task.assigned_to == UX_DEV_1_UUID # deterministic first pick
assert task.source == VIDEO_SOURCE
assert task.status == TS.PENDING
assert task.confirmed_by_human is True # normal delivery task, not CEO-held
# LOW so it clears _check_dev_needs_subtasks; a medium/high root dev task
# would auto-block for subtasks it never owns and deadlock.
assert task.estimated_complexity == Complexity.LOW
assert task.acceptance_criteria # non-empty
project = await db_session.get(ProjectTable, task.project_id)
assert project is not None
assert project.slug == SLUG
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["occasion"] == "release v1.0.0"
assert draft["script"] == "Here's what shipped"
assert draft["platforms"] == ["x", "tiktok"]
assert draft["brief"] == "Announce the release"
@pytest.mark.asyncio
async def test_open_video_task_balances_across_ux_devs(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
first = await engine.open_video_task(
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
)
second = await engine.open_video_task(
occasion="release v2.0.0", script="s", platforms=["x"], brief="b"
)
assert first is not None
assert second is not None
assert first.assigned_to == UX_DEV_1_UUID
assert second.assigned_to == UX_DEV_2_UUID # balanced onto the less-loaded dev
@pytest.mark.asyncio
async def test_open_video_task_dedupes_same_occasion(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
await engine.open_video_task(
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
)
second = await engine.open_video_task(
occasion="release v1.0.0", script="s2", platforms=["x"], brief="b2"
)
assert second is None
open_tasks = await get_task_service(db_session).list_open_video_posts()
assert len(open_tasks) == ONE
@pytest.mark.asyncio
async def test_open_video_task_respects_open_cap(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_max_open_posts=1)
engine = video_engine_module.VideoEngine(db_session)
await engine.open_video_task(
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
)
second = await engine.open_video_task(
occasion="release v2.0.0", script="s", platforms=["x"], brief="b"
)
assert second is None
open_tasks = await get_task_service(db_session).list_open_video_posts()
assert len(open_tasks) == ONE
@pytest.mark.asyncio
async def test_open_video_task_unresolvable_project_opens_nothing(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
monkeypatch.setattr(cfg, "self_heal_project_slug", "no-such-project")
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
)
assert task is None
assert await get_task_service(db_session).list_open_video_posts() == []
@pytest.mark.asyncio
async def test_open_video_task_insert_error_returns_none_session_usable(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""F042 guard: a real DBAPI error on the authoring insert (assignee FK
absent) rolls back ONLY the savepoint and returns None; the shared session
stays usable, so a caller's later commit is not poisoned."""
if await db_session.get(AgentTable, SYSTEM_UUID) is None:
db_session.add(
AgentTable(
id=SYSTEM_UUID,
name="system",
slug="system",
role=AgentRole.SYSTEM,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
# ux-devs deliberately absent -> the assigned_to FK violates at flush.
await db_session.execute(
delete(AgentTable).where(AgentTable.id.in_([UX_DEV_1_UUID, UX_DEV_2_UUID]))
)
has_project = (
await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
).scalar_one_or_none()
if has_project is None:
db_session.add(
ProjectTable(
name="RoboCo",
slug=SLUG,
git_url="https://github.com/x/roboco.git",
default_branch="master",
protected_branches=["master"],
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
is_active=True,
)
)
await db_session.flush()
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.open_video_task(
occasion="fk-fail", script="s", platforms=["x"], brief="b"
)
assert task is None
# Not poisoned: a follow-up statement runs cleanly (this raised
# PendingRollbackError before the savepoint fix).
check = await db_session.execute(
select(ProjectTable).where(ProjectTable.slug == SLUG)
)
assert check.scalar_one_or_none() is not None
# --------------------------------------------------------------------------- #
# _originate_video_post
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_originate_video_post_holds_draft_for_secretary(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch)
engine = video_engine_module.VideoEngine(db_session)
source_task = await engine.open_video_task(
occasion="release v1.0.0",
script="Here's what shipped",
platforms=["x", "tiktok"],
brief="Announce the release",
)
assert source_task is not None
draft_task = await engine._originate_video_post(
source_task=source_task,
mp4_paths={
"vertical": "/render/out/a-vertical.mp4",
"square": "/render/out/a-square.mp4",
},
captions={
"x": "We just shipped v1.0.0!",
"tiktok": "New release, check it out",
},
platforms=["x", "tiktok"],
)
assert draft_task.team == Team.MAIN_PM
assert draft_task.assigned_to == SECRETARY_UUID
assert draft_task.source == VIDEO_POST_SOURCE
assert draft_task.status == TS.PENDING
assert draft_task.confirmed_by_human is False # HELD; never dispatched
draft = markers.get_video_draft(draft_task)
assert draft is not None
assert draft["occasion"] == "release v1.0.0" # carried forward from the source
assert draft["script"] == "Here's what shipped"
assert draft["mp4_paths"] == {
"vertical": "/render/out/a-vertical.mp4",
"square": "/render/out/a-square.mp4",
}
assert draft["x_caption"] == "We just shipped v1.0.0!"
assert draft["tiktok_caption"] == "New release, check it out"
assert draft["platforms"] == ["x", "tiktok"]
assert draft["render_status"] == "rendered"
assert draft["source_task_id"] == str(source_task.id) # traceability back-ref
@pytest.mark.asyncio
async def test_originate_video_post_not_counted_by_dedupe_against_new_occasion(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A materialized post draft still occupies the shared open-cap/dedupe
pool (list_open_video_posts spans both sources) a fresh occasion is
unaffected, but the cap still counts it."""
await _seed(db_session)
_enable(monkeypatch, video_max_open_posts=2)
engine = video_engine_module.VideoEngine(db_session)
source_task = await engine.open_video_task(
occasion="release v1.0.0", script="s", platforms=["x"], brief="b"
)
assert source_task is not None
source_task.status = TS.COMPLETED
await db_session.flush()
await engine._originate_video_post(
source_task=source_task,
mp4_paths={"vertical": "a.mp4", "square": "b.mp4"},
captions={"x": "caption"},
platforms=["x"],
)
# One open (the held draft; the source authoring task is now COMPLETED and
# therefore excluded) plus room for exactly one more before the cap bites.
open_tasks = await get_task_service(db_session).list_open_video_posts()
assert len(open_tasks) == ONE
second = await engine.open_video_task(
occasion="release v2.0.0", script="s", platforms=["x"], brief="b"
)
assert second is not None
open_tasks = await get_task_service(db_session).list_open_video_posts()
assert len(open_tasks) == TWO
# --------------------------------------------------------------------------- #
# draft_release_video
# --------------------------------------------------------------------------- #
_CHANGELOG = "## [1.0.0]\n\n### Added\n- a huge new release\n"
@pytest.mark.asyncio
async def test_draft_release_video_disabled_opens_nothing(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=True)
monkeypatch.setattr(cfg, "video_engine_enabled", False)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
assert task is None
assert await get_task_service(db_session).list_open_video_posts() == []
@pytest.mark.asyncio
async def test_draft_release_video_sub_switch_off_opens_nothing(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=False)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
assert task is None
assert await get_task_service(db_session).list_open_video_posts() == []
@pytest.mark.asyncio
async def test_draft_release_video_opens_authoring_task(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=True)
_mock_local_model(monkeypatch, "RoboCo v1.0.0 just shipped a huge release.")
engine = video_engine_module.VideoEngine(db_session)
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
assert task is not None
assert task.source == VIDEO_SOURCE
assert task.team == Team.UX_UI
assert task.confirmed_by_human is True
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["occasion"] == "release 1.0.0"
assert draft["platforms"] == ["x", "tiktok"]
assert draft["script"] == "RoboCo v1.0.0 just shipped a huge release."
assert draft["brief"] == draft["script"]
@pytest.mark.asyncio
async def test_draft_release_video_falls_back_to_template_on_local_model_failure(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=True)
mock = AsyncMock(side_effect=RuntimeError("ollama down"))
monkeypatch.setattr(video_engine_module, "_chat", mock)
engine = video_engine_module.VideoEngine(db_session)
task = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert "1.0.0" in draft["script"]
assert "a huge new release" in draft["script"]
@pytest.mark.asyncio
async def test_draft_release_video_falls_back_to_template_on_empty_local_reply(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=True)
_mock_local_model(monkeypatch, None) # non-success / empty local-model reply
engine = video_engine_module.VideoEngine(db_session)
task = await engine.draft_release_video(version="2.0.0", changelog="- no bullets")
assert task is not None
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["script"] == "RoboCo v2.0.0 just shipped: no bullets."
@pytest.mark.asyncio
async def test_draft_release_video_dedupes_same_version(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
await _seed(db_session)
_enable(monkeypatch, video_on_release=True)
_mock_local_model(monkeypatch, "shipped!")
engine = video_engine_module.VideoEngine(db_session)
first = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
second = await engine.draft_release_video(version="1.0.0", changelog=_CHANGELOG)
assert first is not None
assert second is None
open_tasks = await get_task_service(db_session).list_open_video_posts()
assert len(open_tasks) == ONE
@@ -0,0 +1,752 @@
"""VideoPostService coverage: approve posts per-platform (idempotent), reject
cancels.
Mirrors the X-post service tests. The heartbeat-mutex acquire/release are
patched (no live Redis in tests, matching the project's `_no_live_redis`
fixture) so approve exercises the real per-platform dispatch + status-
transition path; the run_guarded renew loop is left unpatched its heartbeat
calls fail fast against the poisoned test Redis and are swallowed exactly as
in production, so no separate mock is needed there.
"""
from __future__ import annotations
import asyncio
import contextlib
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import (
AgentRole,
AgentStatus,
Complexity,
Team,
)
from roboco.models.base import TaskNature as TN
from roboco.models.base import TaskStatus as TS
from roboco.models.base import TaskType as TT
from roboco.services.heartbeat_mutex import HeartbeatMutex
from roboco.services.task import VIDEO_POST_SOURCE, TaskService
from roboco.services.video_post_service import (
TikTokPoster,
TikTokUploadResult,
VideoCaptionTooLongError,
VideoPostService,
XVideoPoster,
XVideoPostResult,
get_video_post_service,
)
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
if TYPE_CHECKING:
from uuid import UUID
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
TWO = 2
VERTICAL_MP4 = "/render/out/1-vertical.mp4"
SQUARE_MP4 = "/render/out/1-square.mp4"
class _StubXPoster(XVideoPoster):
def __init__(
self,
*,
posted: bool = True,
video_id: str = "x-vid-1",
raises: bool = False,
) -> None:
self._posted = posted
self._video_id = video_id
self._raises = raises
self.calls: list[tuple[str, str]] = []
@property
def configured(self) -> bool:
return True
async def post_video(self, *, mp4_path: str, caption: str) -> XVideoPostResult:
self.calls.append((mp4_path, caption))
if self._raises:
raise RuntimeError("simulated X network failure")
if not self._posted:
return XVideoPostResult(posted=False, video_id=None, detail="rejected by X")
return XVideoPostResult(posted=True, video_id=self._video_id, detail="posted")
class _StubTikTokPoster(TikTokPoster):
def __init__(
self,
*,
uploaded: bool = True,
publish_id: str = "tt-pub-1",
raises: bool = False,
) -> None:
self._uploaded = uploaded
self._publish_id = publish_id
self._raises = raises
self.calls: list[tuple[str, str]] = []
@property
def configured(self) -> bool:
return True
async def upload_to_inbox(
self, *, mp4_path: str, caption: str
) -> TikTokUploadResult:
self.calls.append((mp4_path, caption))
if self._raises:
raise RuntimeError("simulated TikTok network failure")
if not self._uploaded:
return TikTokUploadResult(
uploaded=False, publish_id=None, detail="rejected by TikTok"
)
return TikTokUploadResult(
uploaded=True, publish_id=self._publish_id, detail="uploaded"
)
async def _seed_video_post(
session: AsyncSession,
*,
platforms: list[str] | None = None,
x_caption: str = "Check out this clip",
tiktok_caption: str = "Check out this clip on TikTok",
) -> TaskTable:
for uuid, slug, role in (
(SYSTEM_UUID, "system", AgentRole.SYSTEM),
(SECRETARY_UUID, "secretary-1", AgentRole.SECRETARY),
):
if await session.get(AgentTable, uuid) is None:
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
project = ProjectTable(
id=uuid4(),
name="RoboCo",
slug=f"roboco-{uuid4().hex[:6]}",
git_url="https://example.com/roboco.git",
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
)
session.add(project)
await session.flush()
task = TaskTable(
id=uuid4(),
title="Video post: release 1.0",
description="script",
acceptance_criteria=["CEO approves or rejects the draft"],
status=TS.PENDING,
priority=2,
task_type=TT.ADMINISTRATIVE,
nature=TN.NON_TECHNICAL,
estimated_complexity=Complexity.LOW,
project_id=project.id,
created_by=SYSTEM_UUID,
assigned_to=SECRETARY_UUID,
team=Team.MAIN_PM,
source=VIDEO_POST_SOURCE,
confirmed_by_human=False,
)
session.add(task)
await session.flush()
markers.set_video_draft(
task,
{
"occasion": "release 1.0",
"script": "script",
"platforms": platforms if platforms is not None else ["x", "tiktok"],
"mp4_paths": {"vertical": VERTICAL_MP4, "square": SQUARE_MP4},
"x_caption": x_caption,
"tiktok_caption": tiktok_caption,
"render_status": "rendered",
},
)
await session.flush()
return task
def _svc(
session: AsyncSession, *, x_poster: XVideoPoster, tiktok_poster: TikTokPoster
) -> VideoPostService:
return get_video_post_service(
session, x_poster=x_poster, tiktok_poster=tiktok_poster
)
def _id(task: TaskTable) -> UUID:
"""The ORM id typed as stdlib ``uuid.UUID`` for service-call sites."""
return cast("UUID", task.id)
_LOCKED = (
patch.object(HeartbeatMutex, "acquire", AsyncMock(return_value="tok")),
patch.object(HeartbeatMutex, "release", AsyncMock(return_value=None)),
)
@pytest.mark.asyncio
async def test_approve_posts_both_platforms_and_completes(
db_session: AsyncSession,
) -> None:
task = await _seed_video_post(db_session)
x_poster = _StubXPoster()
tiktok_poster = _StubTikTokPoster()
with _LOCKED[0], _LOCKED[1]:
result = await _svc(
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
).approve(_id(task))
assert result is not None
assert result.status == "posted"
assert result.posted == {"x": "x-vid-1", "tiktok": "tt-pub-1"}
# X gets the square cut, TikTok the vertical cut.
assert x_poster.calls == [(SQUARE_MP4, "Check out this clip")]
assert tiktok_poster.calls == [(VERTICAL_MP4, "Check out this clip on TikTok")]
await db_session.refresh(task)
assert task.status == TS.COMPLETED
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["x_posted_id"] == "x-vid-1"
assert draft["tiktok_posted_id"] == "tt-pub-1"
@pytest.mark.asyncio
async def test_approve_single_platform_only_calls_that_poster(
db_session: AsyncSession,
) -> None:
task = await _seed_video_post(db_session, platforms=["x"])
x_poster = _StubXPoster()
tiktok_poster = _StubTikTokPoster()
with _LOCKED[0], _LOCKED[1]:
result = await _svc(
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
).approve(_id(task))
assert result is not None
assert result.status == "posted"
assert result.posted == {"x": "x-vid-1"}
assert x_poster.calls == [(SQUARE_MP4, "Check out this clip")]
assert tiktok_poster.calls == [] # never invoked — not in this draft's platforms
@pytest.mark.asyncio
async def test_approve_is_idempotent_second_call_is_noop(
db_session: AsyncSession,
) -> None:
task = await _seed_video_post(db_session)
x_poster = _StubXPoster()
tiktok_poster = _StubTikTokPoster()
svc = _svc(db_session, x_poster=x_poster, tiktok_poster=tiktok_poster)
with _LOCKED[0], _LOCKED[1]:
first = await svc.approve(_id(task))
second = await svc.approve(_id(task))
assert first is not None
assert first.status == "posted"
assert second is not None
assert second.status == "already_posted"
assert second.posted == {"x": "x-vid-1", "tiktok": "tt-pub-1"}
# Neither poster was called a second time.
assert len(x_poster.calls) == 1
assert len(tiktok_poster.calls) == 1
@pytest.mark.asyncio
async def test_approve_concurrent_lock_held_returns_in_progress(
db_session: AsyncSession,
) -> None:
task = await _seed_video_post(db_session)
x_poster = _StubXPoster()
tiktok_poster = _StubTikTokPoster()
with patch.object(HeartbeatMutex, "acquire", AsyncMock(return_value=None)):
result = await _svc(
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
).approve(_id(task))
assert result is not None
assert result.status == "already_in_progress"
assert x_poster.calls == []
assert tiktok_poster.calls == []
@pytest.mark.asyncio
async def test_approve_redis_unavailable_fails_closed(db_session: AsyncSession) -> None:
task = await _seed_video_post(db_session)
x_poster = _StubXPoster()
tiktok_poster = _StubTikTokPoster()
broken = MagicMock()
broken.set = AsyncMock(side_effect=ConnectionError("redis down"))
broken.aclose = AsyncMock()
with patch("roboco.services.heartbeat_mutex.redis.from_url", return_value=broken):
result = await _svc(
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
).approve(_id(task))
assert result is not None
assert result.status == "redis_unavailable"
assert x_poster.calls == []
assert tiktok_poster.calls == []
await db_session.refresh(task)
assert task.status == TS.PENDING # never advanced without the mutex
@pytest.mark.asyncio
async def test_approve_applies_edited_captions_before_posting(
db_session: AsyncSession,
) -> None:
task = await _seed_video_post(db_session)
x_poster = _StubXPoster()
tiktok_poster = _StubTikTokPoster()
with _LOCKED[0], _LOCKED[1]:
result = await _svc(
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
).approve(
_id(task),
x_caption="Edited X caption",
tiktok_caption="Edited TikTok caption",
)
assert result is not None
assert result.status == "posted"
assert x_poster.calls == [(SQUARE_MP4, "Edited X caption")]
assert tiktok_poster.calls == [(VERTICAL_MP4, "Edited TikTok caption")]
@pytest.mark.asyncio
async def test_approve_rejects_edited_x_caption_over_280_chars(
db_session: AsyncSession,
) -> None:
task = await _seed_video_post(db_session)
svc = _svc(db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster())
with pytest.raises(VideoCaptionTooLongError):
await svc.approve(_id(task), x_caption="x" * 281)
@pytest.mark.asyncio
async def test_approve_rejects_edited_tiktok_caption_over_2200_chars(
db_session: AsyncSession,
) -> None:
task = await _seed_video_post(db_session)
svc = _svc(db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster())
with pytest.raises(VideoCaptionTooLongError):
await svc.approve(_id(task), tiktok_caption="x" * 2201)
@pytest.mark.asyncio
async def test_approve_partial_failure_keeps_task_open_and_persists_the_success(
db_session: AsyncSession, _test_database_url: str
) -> None:
"""Unlike every other approve() test in this file, this one commits a
non-terminal (PENDING) video_post row via db_session and never drives it
to a terminal state every other test either never commits (rolled
back at teardown) or ends COMPLETED (excluded from the open-task
queries). Left behind, it leaks into the shared session-scoped test DB
and breaks list_open_video_posts()/list_open_video_post_drafts()
assertions in test_video_engine.py / test_video_render_loop.py, which
run later in the same pytest session. Clean it up via its own
committed session regardless of pass/fail."""
task = await _seed_video_post(db_session)
task_id = _id(task)
project_id = task.project_id
try:
x_poster = _StubXPoster(posted=False)
tiktok_poster = _StubTikTokPoster()
with _LOCKED[0], _LOCKED[1]:
result = await _svc(
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
).approve(task_id)
assert result is not None
assert result.status == "posted_partial"
assert result.posted == {"tiktok": "tt-pub-1"}
await db_session.refresh(task)
assert task.status == TS.PENDING # not completed — X still needs a retry
draft = markers.get_video_draft(task)
assert draft is not None
assert draft["tiktok_posted_id"] == "tt-pub-1"
assert "x_posted_id" not in draft
finally:
cleanup, cleanup_engine = await _fresh_session(_test_database_url)
try:
await cleanup.execute(delete(TaskTable).where(TaskTable.id == task_id))
await cleanup.execute(
delete(ProjectTable).where(ProjectTable.id == project_id)
)
await cleanup.commit()
finally:
await _dispose(cleanup, cleanup_engine)
@pytest.mark.asyncio
async def test_approve_retry_skips_the_already_posted_platform(
db_session: AsyncSession,
) -> None:
"""A retry after a partial failure must not re-post the platform that
already succeeded only the still-pending one is attempted again."""
task = await _seed_video_post(db_session)
x_poster = _StubXPoster(posted=False)
tiktok_poster = _StubTikTokPoster()
svc = _svc(db_session, x_poster=x_poster, tiktok_poster=tiktok_poster)
with _LOCKED[0], _LOCKED[1]:
first = await svc.approve(_id(task))
assert first is not None
assert first.status == "posted_partial"
x_poster._posted = True # credentials fixed between attempts
second = await svc.approve(_id(task))
assert second is not None
assert second.status == "posted"
assert second.posted == {"x": "x-vid-1", "tiktok": "tt-pub-1"}
# TikTok succeeded on attempt 1 and must not be re-uploaded on the retry;
# X failed on attempt 1 so IS legitimately retried (and succeeds this time).
assert len(tiktok_poster.calls) == 1
assert len(x_poster.calls) == TWO
async def _fresh_session(url: str) -> tuple[AsyncSession, AsyncEngine]:
"""A session on a brand-new engine/connection (caller disposes)."""
engine = create_async_engine(url, future=True)
factory = async_sessionmaker(
bind=engine, class_=AsyncSession, expire_on_commit=False
)
return factory(), engine
async def _dispose(session: AsyncSession, engine: AsyncEngine) -> None:
with contextlib.suppress(Exception):
await session.rollback()
await engine.dispose()
@pytest.mark.asyncio
async def test_approve_partial_failure_persists_success_durably_across_sessions(
db_session: AsyncSession, _test_database_url: str
) -> None:
"""`db_session` read-your-own-writes can hide a durability gap: platform
1 (x) posts, platform 2 (tiktok)'s poster RAISES. X's posted-id must be
committed for real visible from a completely INDEPENDENT session and
connection, not merely flushed on the session that ran approve() and a
retry from yet another fresh session must not re-invoke x's poster."""
task = await _seed_video_post(db_session)
task_id = _id(task)
x_poster = _StubXPoster()
tiktok_poster = _StubTikTokPoster(raises=True)
with _LOCKED[0], _LOCKED[1]:
result = await _svc(
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
).approve(task_id)
assert result is not None
assert result.status == "posted_partial"
assert result.posted == {"x": "x-vid-1"}
# A brand-new session/connection — never touched db_session — must see
# x's posted id as durably committed, not just flushed-in-memory there.
fresh, fresh_engine = await _fresh_session(_test_database_url)
try:
fresh_task = await fresh.get(TaskTable, task_id)
assert fresh_task is not None
assert fresh_task.status == TS.PENDING
fresh_draft = markers.get_video_draft(fresh_task)
assert fresh_draft is not None
assert fresh_draft["x_posted_id"] == "x-vid-1"
assert "tiktok_posted_id" not in fresh_draft
finally:
await _dispose(fresh, fresh_engine)
# Retry from yet ANOTHER fresh session (a brand-new request): x must not
# be re-invoked — only the still-pending tiktok platform is retried.
tiktok_poster._raises = False
retry_session, retry_engine = await _fresh_session(_test_database_url)
try:
with _LOCKED[0], _LOCKED[1]:
retry = await _svc(
retry_session, x_poster=x_poster, tiktok_poster=tiktok_poster
).approve(task_id)
finally:
await _dispose(retry_session, retry_engine)
assert retry is not None
assert retry.status == "posted"
assert retry.posted == {"x": "x-vid-1", "tiktok": "tt-pub-1"}
assert len(x_poster.calls) == 1 # never re-invoked on the retry
assert len(tiktok_poster.calls) == TWO # the raise, then the successful retry
@pytest.mark.asyncio
async def test_approve_unknown_task_returns_none(db_session: AsyncSession) -> None:
result = await _svc(
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
).approve(uuid4())
assert result is None
@pytest.mark.asyncio
async def test_reject_records_reason_and_cancels(db_session: AsyncSession) -> None:
task = await _seed_video_post(db_session)
updated = await _svc(
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
).reject(_id(task), "Doesn't match the release")
assert updated is not None
assert updated.status == TS.CANCELLED
assert markers.get_video_reject_reason(updated) == "Doesn't match the release"
@pytest.mark.asyncio
async def test_list_held_video_posts_excludes_terminal(
db_session: AsyncSession,
) -> None:
open_task = await _seed_video_post(db_session)
rejected_task = await _seed_video_post(db_session)
svc = _svc(db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster())
await svc.reject(_id(rejected_task), "not relevant")
held = await svc.list_held_video_posts()
ids = {t.id for t in held}
assert open_task.id in ids
assert rejected_task.id not in ids
@pytest.mark.asyncio
async def test_approve_commits_before_releasing_the_lock(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The double-post guard depends on this ordering: every commit (each
platform success, then COMPLETED) must be durable before the lock is
released, or a racing approve could acquire the lock the instant it's
dropped and post again before a commit lands. Both platforms succeed
here, so this lands 3 commits (one per platform + COMPLETED) asserts
the ordering property, not a hard-coded count of an implementation
detail."""
task = await _seed_video_post(db_session)
order: list[str] = []
real_commit = db_session.commit
async def _spy_commit() -> None:
await real_commit()
order.append("commit")
monkeypatch.setattr(db_session, "commit", _spy_commit)
async def _spy_release(_self: HeartbeatMutex, _token: str) -> None:
order.append("release")
with (
patch.object(HeartbeatMutex, "acquire", AsyncMock(return_value="tok")),
patch.object(HeartbeatMutex, "release", _spy_release),
):
result = await _svc(
db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster()
).approve(_id(task))
assert result is not None
assert result.status == "posted"
assert order[-1] == "release"
assert order.count("release") == 1
assert order[:-1] == ["commit"] * (len(order) - 1)
assert len(order) > 1 # at least one commit landed before the release
@pytest.mark.asyncio
async def test_approve_rechecks_completed_under_lock_and_never_reposts(
db_session: AsyncSession,
) -> None:
"""A concurrent approve wins the lock, posts, and commits COMPLETED after
our pre-lock read. Once we acquire the lock, the in-lock re-read must see
COMPLETED and short-circuit never re-posting."""
task = await _seed_video_post(db_session)
x_poster = _StubXPoster()
tiktok_poster = _StubTikTokPoster()
async def _win_the_race(_self: HeartbeatMutex) -> str:
draft = dict(markers.get_video_draft(task) or {})
draft["x_posted_id"] = "x-winner"
draft["tiktok_posted_id"] = "tt-winner"
markers.set_video_draft(task, draft)
task.status = TS.COMPLETED
await db_session.flush()
return "tok"
with (
patch.object(HeartbeatMutex, "acquire", _win_the_race),
patch.object(HeartbeatMutex, "release", AsyncMock(return_value=None)),
):
result = await _svc(
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
).approve(_id(task))
assert result is not None
assert result.status == "already_posted"
assert result.posted == {"x": "x-winner", "tiktok": "tt-winner"}
assert x_poster.calls == []
assert tiktok_poster.calls == []
@pytest.mark.asyncio
async def test_approve_concurrent_caption_edit_does_not_erase_a_committed_posted_id(
db_session: AsyncSession, _test_database_url: str
) -> None:
"""RC1 regression: a caption edit must never write the draft column
before the lock. The old pre-lock flush computed its update from a read
taken before a genuinely concurrent approve (a different session/
connection) posted + committed a platform's id; once THIS session later
commits under the lock, that stale write won and erased the concurrent
commit the retry re-posted the platform (a double-post)."""
task = await _seed_video_post(db_session, platforms=["x", "tiktok"])
task_id = _id(task)
await db_session.commit() # externally visible to the "concurrent" session below
real_get = TaskService.get
injected = False
async def _get_then_inject_concurrent_post(
self: TaskService, tid: UUID
) -> TaskTable | None:
"""Fires once, right after the outer pre-lock read — the exact
window between our read and our own (would-be) pre-lock write."""
nonlocal injected
result = await real_get(self, tid)
if not injected:
injected = True
other, other_engine = await _fresh_session(_test_database_url)
try:
other_task = await other.get(TaskTable, tid)
assert other_task is not None
other_draft = dict(markers.get_video_draft(other_task) or {})
other_draft["x_posted_id"] = "x-concurrent"
markers.set_video_draft(other_task, other_draft)
await other.commit()
finally:
await _dispose(other, other_engine)
return result
x_poster = _StubXPoster()
tiktok_poster = _StubTikTokPoster()
with (
patch.object(TaskService, "get", _get_then_inject_concurrent_post),
_LOCKED[0],
_LOCKED[1],
):
result = await _svc(
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
).approve(task_id, x_caption="Edited X caption")
assert result is not None
# X must never be re-posted — the concurrently committed id survives.
assert x_poster.calls == []
assert result.posted.get("x") == "x-concurrent"
assert tiktok_poster.calls == [(VERTICAL_MP4, "Check out this clip on TikTok")]
fresh, fresh_engine = await _fresh_session(_test_database_url)
try:
final = await fresh.get(TaskTable, task_id)
assert final is not None
final_draft = markers.get_video_draft(final)
assert final_draft is not None
assert final_draft["x_posted_id"] == "x-concurrent" # not erased
assert final_draft["x_caption"] == "Edited X caption" # edit still applied
finally:
await _dispose(fresh, fresh_engine)
@pytest.mark.asyncio
async def test_approve_cancel_during_a_platform_commit_leaves_it_durable_and_usable(
db_session: AsyncSession,
monkeypatch: pytest.MonkeyPatch,
_test_database_url: str,
) -> None:
"""RC2 regression: a lock-loss cancellation firing while a per-platform
commit is in flight used to interrupt asyncio's own await on that
commit rolling it back (so a retry re-posts the same platform) and
leaving the session unusable for anything that runs after (a route's
get_db teardown 500s on the poisoned session). Fixed: the commit is
asyncio.shield()ed so it always finishes, and the lock_lost path rolls
back explicitly so the session is guaranteed clean for reuse.
A lock_lost result never reaches _finalize_post, so the task is left
PENDING (non-terminal) even though its one platform posted unlike
every other approve() test in this file, this one must clean up its own
committed row (see the note on test_approve_partial_failure_keeps_task_
open_and_persists_the_success for why that matters)."""
task = await _seed_video_post(db_session, platforms=["x"])
task_id = _id(task)
project_id = task.project_id
await db_session.commit() # a prior committed transaction, isolating the
# racy platform-commit below to its OWN transaction — so a rollback of
# that transaction can't also undo the seed itself.
try:
x_poster = _StubXPoster()
tiktok_poster = _StubTikTokPoster()
real_commit = db_session.commit
commit_started = asyncio.Event()
async def _slow_commit() -> None:
commit_started.set()
await real_commit()
monkeypatch.setattr(db_session, "commit", _slow_commit)
async def _lose_the_lock_once_committing(
_self: HeartbeatMutex, _token: str
) -> bool:
await commit_started.wait()
return False # lock lost -- cancels the guarded task fail-closed
with (
patch.object(HeartbeatMutex, "acquire", AsyncMock(return_value="tok")),
patch.object(HeartbeatMutex, "release", AsyncMock(return_value=None)),
patch.object(
HeartbeatMutex, "heartbeat_once", _lose_the_lock_once_committing
),
):
result = await _svc(
db_session, x_poster=x_poster, tiktok_poster=tiktok_poster
).approve(task_id)
assert result is not None
assert result.status == "lock_lost"
assert x_poster.calls == [(SQUARE_MP4, "Check out this clip")] # it DID post
# Durable: a brand-new connection sees the id even though the
# guarded task was cancelled mid-commit.
fresh, fresh_engine = await _fresh_session(_test_database_url)
try:
final = await fresh.get(TaskTable, task_id)
assert final is not None
final_draft = markers.get_video_draft(final)
assert final_draft is not None
assert final_draft["x_posted_id"] == "x-vid-1"
finally:
await _dispose(fresh, fresh_engine)
# The session itself must still be usable afterward — not poisoned
# by the cancelled-mid-commit path.
monkeypatch.setattr(db_session, "commit", real_commit)
check = await db_session.get(TaskTable, task_id)
assert check is not None
await db_session.commit() # would raise if the session were poisoned
finally:
cleanup, cleanup_engine = await _fresh_session(_test_database_url)
try:
await cleanup.execute(delete(TaskTable).where(TaskTable.id == task_id))
await cleanup.execute(
delete(ProjectTable).where(ProjectTable.id == project_id)
)
await cleanup.commit()
finally:
await _dispose(cleanup, cleanup_engine)
+244
View File
@@ -0,0 +1,244 @@
"""LiveXVideoPoster coverage: the v2 media-upload sequence (initialize ->
append -> finalize -> STATUS poll -> tweet) against a fake httpx transport,
plus the Null/Live build branching."""
from __future__ import annotations
from typing import TYPE_CHECKING
import httpx
import pytest
from roboco.services.video_post_service import NullXVideoPoster
from roboco.services.x_credentials import XCredentialsData
from roboco.services.x_video_client import (
LiveXVideoPoster,
build_x_video_poster,
)
if TYPE_CHECKING:
from pathlib import Path
_CREDS = XCredentialsData(
api_key="ak-test",
api_secret="as-test",
access_token="at-test",
access_token_secret="ats-test",
)
THREE = 3
def test_build_without_creds_returns_null() -> None:
poster = build_x_video_poster(None, timeout=5.0)
assert isinstance(poster, NullXVideoPoster)
assert poster.configured is False
def test_build_with_creds_returns_live() -> None:
poster = build_x_video_poster(_CREDS, timeout=5.0)
assert isinstance(poster, LiveXVideoPoster)
assert poster.configured is True
@pytest.mark.asyncio
async def test_null_poster_post_video_is_a_noop() -> None:
result = await NullXVideoPoster().post_video(mp4_path="/tmp/x.mp4", caption="hi")
assert result.posted is False
assert result.video_id is None
def _write_clip(tmp_path: Path, data: bytes = b"fake-mp4-bytes") -> str:
mp4 = tmp_path / "clip.mp4"
mp4.write_bytes(data)
return str(mp4)
@pytest.mark.asyncio
async def test_post_video_full_sequence_with_processing_poll(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The whole verified §11.2 sequence: initialize -> append -> finalize
(pending) -> GET STATUS (succeeded) -> POST /2/tweets."""
calls: list[tuple[str, str]] = []
async def _no_sleep(_seconds: float) -> None:
return None
monkeypatch.setattr("roboco.services.x_video_client.asyncio.sleep", _no_sleep)
def handler(request: httpx.Request) -> httpx.Response:
calls.append((request.method, request.url.path))
assert request.headers["Authorization"].startswith("OAuth ")
path = request.url.path
if path == "/2/media/upload/initialize":
return httpx.Response(
202, json={"data": {"id": "media123", "media_key": "3_123"}}
)
if path == "/2/media/upload/media123/append":
return httpx.Response(204)
if path == "/2/media/upload/media123/finalize":
return httpx.Response(
201,
json={
"data": {
"id": "media123",
"processing_info": {"state": "pending", "check_after_secs": 1},
}
},
)
if path == "/2/media/upload" and request.url.params.get("command") == "STATUS":
assert request.url.params.get("media_id") == "media123"
return httpx.Response(
200, json={"data": {"processing_info": {"state": "succeeded"}}}
)
if path == "/2/tweets":
return httpx.Response(201, json={"data": {"id": "tweet789"}})
raise AssertionError(f"unexpected request {request.method} {path}")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
poster = LiveXVideoPoster(_CREDS, timeout=5.0, client=http_client)
result = await poster.post_video(
mp4_path=_write_clip(tmp_path), caption="Check this out"
)
await http_client.aclose()
assert result.posted is True
assert result.video_id == "tweet789"
paths = [p for _, p in calls]
assert paths.count("/2/media/upload/initialize") == 1
assert paths.count("/2/media/upload/media123/append") == 1
assert paths.count("/2/media/upload/media123/finalize") == 1
assert paths.count("/2/media/upload") == 1 # the STATUS poll
assert paths.count("/2/tweets") == 1
@pytest.mark.asyncio
async def test_post_video_skips_poll_when_finalize_has_no_processing_info(
tmp_path: Path,
) -> None:
"""A finalize response with no processing_info means already usable —
no STATUS poll is issued at all."""
calls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
path = request.url.path
if path == "/2/media/upload/initialize":
return httpx.Response(202, json={"data": {"id": "media1"}})
if path == "/2/media/upload/media1/append":
return httpx.Response(204)
if path == "/2/media/upload/media1/finalize":
return httpx.Response(201, json={"data": {"id": "media1"}})
if path == "/2/tweets":
return httpx.Response(201, json={"data": {"id": "tweet1"}})
raise AssertionError(f"unexpected request {path}")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
poster = LiveXVideoPoster(_CREDS, timeout=5.0, client=http_client)
result = await poster.post_video(mp4_path=_write_clip(tmp_path), caption="hi")
await http_client.aclose()
assert result.posted is True
assert "/2/media/upload" not in calls # STATUS GET never fired
@pytest.mark.asyncio
async def test_post_video_appends_in_multiple_chunks(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A file bigger than the chunk size is appended across several calls
(shrink the module chunk size rather than allocating real megabytes)."""
monkeypatch.setattr("roboco.services.x_video_client._CHUNK_SIZE_BYTES", 4)
append_calls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path == "/2/media/upload/initialize":
return httpx.Response(202, json={"data": {"id": "media1"}})
if path == "/2/media/upload/media1/append":
append_calls.append(str(request.content))
return httpx.Response(204)
if path == "/2/media/upload/media1/finalize":
return httpx.Response(201, json={"data": {"id": "media1"}})
if path == "/2/tweets":
return httpx.Response(201, json={"data": {"id": "tweet1"}})
raise AssertionError(f"unexpected request {path}")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
poster = LiveXVideoPoster(_CREDS, timeout=5.0, client=http_client)
result = await poster.post_video(
mp4_path=_write_clip(tmp_path, b"0123456789"), caption="hi"
)
await http_client.aclose()
assert result.posted is True
assert len(append_calls) == THREE # ceil(10 / 4) == 3 chunks
@pytest.mark.asyncio
async def test_post_video_initialize_failure_is_graceful(tmp_path: Path) -> None:
def handler(_request: httpx.Request) -> httpx.Response:
return httpx.Response(401, text="unauthorized")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
poster = LiveXVideoPoster(_CREDS, timeout=5.0, client=http_client)
result = await poster.post_video(mp4_path=_write_clip(tmp_path), caption="hi")
await http_client.aclose()
assert result.posted is False
assert result.video_id is None
assert "401" in result.detail
@pytest.mark.asyncio
async def test_post_video_processing_failed_state_is_graceful(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
async def _no_sleep(_seconds: float) -> None:
return None
monkeypatch.setattr("roboco.services.x_video_client.asyncio.sleep", _no_sleep)
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path == "/2/media/upload/initialize":
return httpx.Response(202, json={"data": {"id": "media1"}})
if path == "/2/media/upload/media1/append":
return httpx.Response(204)
if path == "/2/media/upload/media1/finalize":
return httpx.Response(
201,
json={
"data": {
"processing_info": {
"state": "pending",
"check_after_secs": 0,
}
}
},
)
if path == "/2/media/upload" and request.url.params.get("command") == "STATUS":
return httpx.Response(
200,
json={
"data": {
"processing_info": {
"state": "failed",
"error": {"message": "invalid video format"},
}
}
},
)
raise AssertionError(f"unexpected request {path}")
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
poster = LiveXVideoPoster(_CREDS, timeout=5.0, client=http_client)
result = await poster.post_video(mp4_path=_write_clip(tmp_path), caption="hi")
await http_client.aclose()
assert result.posted is False
assert "processing failed" in result.detail