mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* feat(video): pipeline visibility — strip, state-aware queue, render-error capture Task 1 of the 2026-07-09 video-pipeline review. New CEO-gated GET /video/pipeline lists every in-flight video item (authoring statuses, rendering attempt n/max, terminal failures with the error — now stamped onto the video_draft marker instead of dying as a log line). source_task_id exposed on both video schemas. Panel: pipeline strip on the Social page, state-aware queue empty copy, title/script on queue rows, missing cuts disabled instead of a blank player, notifications deep-link related_task_id. MAX_VIDEO_RENDER_ATTEMPTS moved to the markers policy layer (single source of truth). * feat(video): rich authoring briefs — changelog section, brand voice, kit pointer Task 2 of the 2026-07-09 video-pipeline review. The release brief is now a structured block (full CHANGELOG section capped at 4000 chars + highlights) instead of one LLM-compressed sentence; brand_voice and a motion/kit design-bar pointer are appended centrally in open_video_task so release, spotlight, and on-demand paths all inherit them. suggested_input_props seeded on the video_draft marker; third acceptance criterion pins the design bar; propose_video docstring points at the kit. * fix(video): spotlight video drafts on CEO approval, renderer honors data-fps Task 4 of the 2026-07-09 video-pipeline review. The companion-video hook moves from propose_feature_spotlight (HoM authoring time) to XPostService approve's posted-success branch for x_feature drafts, mirroring the release-publish seam — a rejected spotlight no longer burns a ux-dev cycle; wants_video/video_script ride the x_feature_ref marker. Best-effort: a video-engine failure never breaks the post. render.js reads data-fps from the composition HTML (clamped 24-60, fallback 30) instead of hardcoding 30; parseFps covered by node --test. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
720 lines
27 KiB
Python
720 lines
27 KiB
Python
"""XPostService coverage: approve posts (idempotent), reject cancels.
|
|
|
|
Mirrors the release-proposal service tests. The Redis lock helpers are
|
|
patched (no live Redis in tests, matching the project's ``_no_live_redis``
|
|
fixture) so approve exercises the real post + status-transition path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, cast
|
|
from unittest.mock import AsyncMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
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.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.task import X_FEATURE_SOURCE, X_POST_SOURCE, X_REPLY_SOURCE
|
|
from roboco.services.x_client import XClient, XMention, XPostResult
|
|
from roboco.services.x_post_service import (
|
|
TaskAlreadyCompletedError,
|
|
XPostBodyTooLongError,
|
|
XPostExecuteResult,
|
|
XPostService,
|
|
get_x_post_service,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
|
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
|
|
ONE = 1
|
|
TWO = 2
|
|
|
|
|
|
class _StubClient(XClient):
|
|
def __init__(self, *, posted: bool = True, tweet_id: str = "999") -> None:
|
|
self._posted = posted
|
|
self._tweet_id = tweet_id
|
|
self.calls: list[str] = []
|
|
|
|
@property
|
|
def configured(self) -> bool:
|
|
return True
|
|
|
|
async def post_tweet(self, text: str) -> XPostResult:
|
|
self.calls.append(text)
|
|
if not self._posted:
|
|
return XPostResult(posted=False, tweet_id=None, detail="rejected by X")
|
|
return XPostResult(posted=True, tweet_id=self._tweet_id, detail="posted")
|
|
|
|
async def fetch_mentions(
|
|
self, since_id: str | None, max_results: int
|
|
) -> list[XMention]:
|
|
_ = (since_id, max_results)
|
|
return []
|
|
|
|
|
|
async def _seed_draft(
|
|
session: AsyncSession, *, source: str = X_POST_SOURCE, body: str = "Draft body"
|
|
) -> 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="X draft",
|
|
description=body,
|
|
acceptance_criteria=["CEO approves or rejects"],
|
|
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=source,
|
|
confirmed_by_human=False,
|
|
)
|
|
session.add(task)
|
|
await session.flush()
|
|
markers.set_x_draft_body(task, body)
|
|
await session.flush()
|
|
return task
|
|
|
|
|
|
_FEATURE_SLUG = "org-memory"
|
|
_FEATURE_TITLE = "Organizational Memory Loop"
|
|
|
|
|
|
async def _seed_feature_draft(
|
|
session: AsyncSession,
|
|
*,
|
|
wants_video: bool = True,
|
|
video_script: str = "",
|
|
body: str = "Draft body",
|
|
) -> TaskTable:
|
|
"""An X_FEATURE_SOURCE draft carrying the x_feature_ref marker
|
|
``propose_feature_spotlight`` stamps (Task 4, 2026-07-09 pipeline fixes):
|
|
slug/title always, plus wants_video/video_script when a companion video
|
|
was requested at authoring time."""
|
|
task = await _seed_draft(session, source=X_FEATURE_SOURCE, body=body)
|
|
markers.set_x_feature_ref(
|
|
task,
|
|
{
|
|
"slug": _FEATURE_SLUG,
|
|
"title": _FEATURE_TITLE,
|
|
"wants_video": wants_video,
|
|
"video_script": video_script,
|
|
},
|
|
)
|
|
await session.flush()
|
|
return task
|
|
|
|
|
|
def _enable_video(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(cfg, "video_engine_enabled", True)
|
|
monkeypatch.setattr(cfg, "video_on_spotlight", True)
|
|
|
|
|
|
def _svc(session: AsyncSession) -> XPostService:
|
|
return get_x_post_service(session)
|
|
|
|
|
|
def _id(task: TaskTable) -> UUID:
|
|
"""The ORM id typed as stdlib ``uuid.UUID`` for service-call sites."""
|
|
return cast("UUID", task.id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_posts_and_completes(db_session: AsyncSession) -> None:
|
|
task = await _seed_draft(db_session)
|
|
client = _StubClient()
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
):
|
|
result = await _svc(db_session).approve(_id(task))
|
|
assert result is not None
|
|
assert result.status == "posted"
|
|
assert result.tweet_id == "999"
|
|
assert client.calls == ["Draft body"]
|
|
await db_session.refresh(task)
|
|
assert task.status == TS.COMPLETED
|
|
assert markers.get_x_posted_tweet_id(task) == "999"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_is_idempotent_second_call_is_noop(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
task = await _seed_draft(db_session)
|
|
client = _StubClient()
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
):
|
|
svc = _svc(db_session)
|
|
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.tweet_id == "999"
|
|
# The X client was called exactly once — the second approve never re-posts.
|
|
assert client.calls == ["Draft body"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_with_edited_body_posts_the_edit(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
task = await _seed_draft(db_session, body="Original")
|
|
client = _StubClient()
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
):
|
|
result = await _svc(db_session).approve(_id(task), "Edited body")
|
|
assert result is not None
|
|
assert result.status == "posted"
|
|
assert client.calls == ["Edited body"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_rejects_edited_body_over_280_chars(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
task = await _seed_draft(db_session)
|
|
with pytest.raises(XPostBodyTooLongError):
|
|
await _svc(db_session).approve(_id(task), "x" * 281)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_no_credentials_result(db_session: AsyncSession) -> None:
|
|
task = await _seed_draft(db_session)
|
|
no_creds_client = _StubClient()
|
|
|
|
class _Null(XClient):
|
|
@property
|
|
def configured(self) -> bool:
|
|
return False
|
|
|
|
async def post_tweet(self, text: str) -> XPostResult:
|
|
_ = text
|
|
return XPostResult(posted=False, tweet_id=None, detail="no creds")
|
|
|
|
async def fetch_mentions(
|
|
self, since_id: str | None, max_results: int
|
|
) -> list[XMention]:
|
|
_ = (since_id, max_results)
|
|
return []
|
|
|
|
_ = no_creds_client
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=_Null()),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
):
|
|
result = await _svc(db_session).approve(_id(task))
|
|
assert result is not None
|
|
assert result.status == "no_credentials"
|
|
await db_session.refresh(task)
|
|
assert task.status == TS.PENDING # never advanced without credentials
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_post_failed_keeps_task_open(db_session: AsyncSession) -> None:
|
|
task = await _seed_draft(db_session)
|
|
client = _StubClient(posted=False)
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
):
|
|
result = await _svc(db_session).approve(_id(task))
|
|
assert result is not None
|
|
assert result.status == "post_failed"
|
|
await db_session.refresh(task)
|
|
assert task.status == TS.PENDING
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_rechecks_completed_under_lock_and_never_reposts(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""The double-post guard: 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_draft(db_session)
|
|
client = _StubClient()
|
|
|
|
async def _win_the_race(_self: XPostService, _key: str) -> str:
|
|
# Simulate the concurrent winner: the row is COMPLETED (posted) by the
|
|
# time we hold the lock, exactly as the in-lock expire()+re-fetch sees.
|
|
markers.set_x_posted_tweet_id(task, "111")
|
|
task.status = TS.COMPLETED
|
|
await db_session.flush()
|
|
return "tok"
|
|
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", _win_the_race),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
):
|
|
result = await _svc(db_session).approve(_id(task))
|
|
assert result is not None
|
|
assert result.status == "already_posted"
|
|
assert result.tweet_id == "111"
|
|
# The tweet was never posted a second time.
|
|
assert client.calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_concurrent_lock_held_returns_in_progress(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
task = await _seed_draft(db_session)
|
|
with patch.object(XPostService, "_acquire_lock", AsyncMock(return_value=None)):
|
|
result = await _svc(db_session).approve(_id(task))
|
|
assert result is not None
|
|
assert result.status == "already_in_progress"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_unknown_task_returns_none(db_session: AsyncSession) -> None:
|
|
result = await _svc(db_session).approve(uuid4())
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reject_records_reason_and_cancels(db_session: AsyncSession) -> None:
|
|
task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
|
|
updated = await _svc(db_session).reject(_id(task), "Tone doesn't match our voice")
|
|
assert updated is not None
|
|
assert updated.status == TS.CANCELLED
|
|
assert markers.get_x_reject_reason(updated) == "Tone doesn't match our voice"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_open_posts_excludes_terminal(db_session: AsyncSession) -> None:
|
|
open_task = await _seed_draft(db_session)
|
|
rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
|
|
await _svc(db_session).reject(_id(rejected_task), "not relevant")
|
|
open_posts = await _svc(db_session).list_open_posts()
|
|
ids = {t.id for t in open_posts}
|
|
assert open_task.id in ids
|
|
assert rejected_task.id not in ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_posts_feature_spotlight_draft(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""The feature-spotlight source rides the same generic post path as
|
|
x_post/x_reply; it only branches for the best-effort video hook below
|
|
(a no-op here since this draft carries no x_feature_ref marker)."""
|
|
task = await _seed_draft(db_session, source=X_FEATURE_SOURCE)
|
|
client = _StubClient()
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
):
|
|
result = await _svc(db_session).approve(_id(task))
|
|
assert result is not None
|
|
assert result.status == "posted"
|
|
assert result.tweet_id == "999"
|
|
assert client.calls == ["Draft body"]
|
|
await db_session.refresh(task)
|
|
assert task.status == TS.COMPLETED
|
|
assert markers.get_x_posted_tweet_id(task) == "999"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_open_posts_includes_feature_spotlight_source(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
task = await _seed_draft(db_session, source=X_FEATURE_SOURCE)
|
|
open_posts = await _svc(db_session).list_open_posts()
|
|
ids = {t.id for t in open_posts}
|
|
assert task.id in ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reject_completed_raises(db_session: AsyncSession) -> None:
|
|
"""An already-posted (COMPLETED) draft is live on X; rejecting it would
|
|
lie "cancelled (never posted)" while the tweet is public."""
|
|
task = await _seed_draft(db_session)
|
|
markers.set_x_posted_tweet_id(task, "999")
|
|
task.status = TS.COMPLETED
|
|
await db_session.flush()
|
|
with pytest.raises(TaskAlreadyCompletedError):
|
|
await _svc(db_session).reject(_id(task), "nope")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_post_history_excludes_open_drafts(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
open_task = await _seed_draft(db_session)
|
|
rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
|
|
await _svc(db_session).reject(_id(rejected_task), "not relevant")
|
|
history = await _svc(db_session).list_post_history()
|
|
ids = {t.id for t in history}
|
|
assert rejected_task.id in ids
|
|
assert open_task.id not in ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_post_history_newest_acted_first(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
|
|
await _svc(db_session).reject(_id(rejected_task), "not relevant")
|
|
posted_task = await _seed_draft(db_session)
|
|
client = _StubClient()
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
):
|
|
await _svc(db_session).approve(_id(posted_task))
|
|
history = await _svc(db_session).list_post_history()
|
|
ids = [t.id for t in history]
|
|
assert ids.index(posted_task.id) < ids.index(rejected_task.id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_post_history_includes_marker_fields(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
posted_task = await _seed_draft(db_session)
|
|
client = _StubClient(tweet_id="777")
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
):
|
|
await _svc(db_session).approve(_id(posted_task))
|
|
rejected_task = await _seed_draft(db_session, source=X_REPLY_SOURCE)
|
|
await _svc(db_session).reject(_id(rejected_task), "off-brand tone")
|
|
|
|
history = await _svc(db_session).list_post_history()
|
|
by_id = {t.id: t for t in history}
|
|
assert markers.get_x_posted_tweet_id(by_id[posted_task.id]) == "777"
|
|
assert markers.get_x_reject_reason(by_id[rejected_task.id]) == "off-brand tone"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_post_history_respects_limit(db_session: AsyncSession) -> None:
|
|
tasks = []
|
|
for _ in range(3):
|
|
t = await _seed_draft(db_session, source=X_REPLY_SOURCE)
|
|
await _svc(db_session).reject(_id(t), "not relevant")
|
|
tasks.append(t)
|
|
history = await _svc(db_session).list_post_history(limit=2)
|
|
assert len(history) == TWO
|
|
ids = {t.id for t in history}
|
|
assert tasks[2].id in ids
|
|
assert tasks[1].id in ids
|
|
assert tasks[0].id not in ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_does_not_flush_edited_body_before_lock(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""The CEO's edit must land on the re-read locked row INSIDE the critical
|
|
section, after the COMPLETED check — never on the pre-lock row. A concurrent
|
|
approve that already posted (locked row COMPLETED) must not have this edit
|
|
overwrite the just-posted task's stored body."""
|
|
task = await _seed_draft(db_session, body="Original")
|
|
original_body = markers.get_x_draft_body(task)
|
|
|
|
async def _already_posted_locked(
|
|
_self: XPostService, _task_id: UUID, _task: TaskTable, _trimmed: str | None
|
|
) -> XPostExecuteResult:
|
|
return XPostExecuteResult(
|
|
status="already_posted",
|
|
tweet_id="111",
|
|
detail="this draft was already posted",
|
|
)
|
|
|
|
with (
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
patch.object(XPostService, "_approve_locked", _already_posted_locked),
|
|
):
|
|
result = await _svc(db_session).approve(_id(task), "new body")
|
|
assert result is not None
|
|
assert result.status == "already_posted"
|
|
await db_session.refresh(task)
|
|
assert markers.get_x_draft_body(task) == original_body
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Spotlight video hook (Task 4, 2026-07-09 pipeline fixes): moved from
|
|
# authoring time (propose_feature_spotlight) to this posted-success branch so
|
|
# a ux-dev never burns a cycle on a spotlight the CEO then rejects.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_feature_spotlight_with_video_opens_video_task(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_enable_video(monkeypatch)
|
|
task = await _seed_feature_draft(db_session, video_script="Custom voiceover script")
|
|
client = _StubClient()
|
|
video_engine = AsyncMock()
|
|
video_engine.open_video_task = AsyncMock(return_value=None)
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
patch(
|
|
"roboco.services.video_engine.get_video_engine",
|
|
return_value=video_engine,
|
|
),
|
|
):
|
|
result = await _svc(db_session).approve(_id(task))
|
|
assert result is not None
|
|
assert result.status == "posted"
|
|
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"]
|
|
assert kwargs["script"] == "Custom voiceover script"
|
|
assert kwargs["brief"] == "Organizational Memory Loop: Draft body"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_feature_spotlight_video_falls_back_to_brief_script(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""No explicit video_script -> script falls back to the brief, mirroring
|
|
the fallback the authoring-time hook used to do."""
|
|
_enable_video(monkeypatch)
|
|
task = await _seed_feature_draft(db_session)
|
|
client = _StubClient()
|
|
video_engine = AsyncMock()
|
|
video_engine.open_video_task = AsyncMock(return_value=None)
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
patch(
|
|
"roboco.services.video_engine.get_video_engine",
|
|
return_value=video_engine,
|
|
),
|
|
):
|
|
result = await _svc(db_session).approve(_id(task))
|
|
assert result is not None
|
|
assert result.status == "posted"
|
|
kwargs = video_engine.open_video_task.call_args.kwargs
|
|
expected_brief = "Organizational Memory Loop: Draft body"
|
|
assert kwargs["script"] == expected_brief
|
|
assert kwargs["brief"] == expected_brief
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_feature_spotlight_reapprove_does_not_reopen_video(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Idempotent re-approve: the second call short-circuits on the already-
|
|
COMPLETED check before ever reaching _post/_open_spotlight_video again."""
|
|
_enable_video(monkeypatch)
|
|
task = await _seed_feature_draft(db_session)
|
|
client = _StubClient()
|
|
video_engine = AsyncMock()
|
|
video_engine.open_video_task = AsyncMock(return_value=None)
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
patch(
|
|
"roboco.services.video_engine.get_video_engine",
|
|
return_value=video_engine,
|
|
),
|
|
):
|
|
svc = _svc(db_session)
|
|
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"
|
|
video_engine.open_video_task.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_plain_x_post_never_opens_video(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A plain x_post draft carries no x_feature_ref, so the source check
|
|
alone keeps the video hook from ever firing for it."""
|
|
_enable_video(monkeypatch)
|
|
task = await _seed_draft(db_session, source=X_POST_SOURCE)
|
|
client = _StubClient()
|
|
video_engine = AsyncMock()
|
|
video_engine.open_video_task = AsyncMock(return_value=None)
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
patch(
|
|
"roboco.services.video_engine.get_video_engine",
|
|
return_value=video_engine,
|
|
),
|
|
):
|
|
result = await _svc(db_session).approve(_id(task))
|
|
assert result is not None
|
|
assert result.status == "posted"
|
|
video_engine.open_video_task.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reject_feature_spotlight_with_wants_video_opens_none(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Rejecting a spotlight draft never posts, so the video hook (which only
|
|
fires from the posted-success branch of _post) never runs either."""
|
|
_enable_video(monkeypatch)
|
|
task = await _seed_feature_draft(db_session)
|
|
video_engine = AsyncMock()
|
|
video_engine.open_video_task = AsyncMock(return_value=None)
|
|
with patch(
|
|
"roboco.services.video_engine.get_video_engine",
|
|
return_value=video_engine,
|
|
):
|
|
updated = await _svc(db_session).reject(_id(task), "not on-brand")
|
|
assert updated is not None
|
|
assert updated.status == TS.CANCELLED
|
|
video_engine.open_video_task.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_feature_spotlight_video_flags_off_skips(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(cfg, "video_engine_enabled", False)
|
|
monkeypatch.setattr(cfg, "video_on_spotlight", False)
|
|
task = await _seed_feature_draft(db_session)
|
|
client = _StubClient()
|
|
video_engine = AsyncMock()
|
|
video_engine.open_video_task = AsyncMock(return_value=None)
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
patch(
|
|
"roboco.services.video_engine.get_video_engine",
|
|
return_value=video_engine,
|
|
),
|
|
):
|
|
result = await _svc(db_session).approve(_id(task))
|
|
assert result is not None
|
|
assert result.status == "posted"
|
|
video_engine.open_video_task.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_feature_spotlight_without_wants_video_skips(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Flags on but the draft's author didn't request a video (wants_video
|
|
absent/False on the marker) -> no video task, distinct from the
|
|
flags-off case above."""
|
|
_enable_video(monkeypatch)
|
|
task = await _seed_feature_draft(db_session, wants_video=False)
|
|
client = _StubClient()
|
|
video_engine = AsyncMock()
|
|
video_engine.open_video_task = AsyncMock(return_value=None)
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
patch(
|
|
"roboco.services.video_engine.get_video_engine",
|
|
return_value=video_engine,
|
|
),
|
|
):
|
|
result = await _svc(db_session).approve(_id(task))
|
|
assert result is not None
|
|
assert result.status == "posted"
|
|
video_engine.open_video_task.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_feature_spotlight_video_failure_does_not_break_post(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Best-effort: a video-engine blow-up must not affect the already-
|
|
succeeded post."""
|
|
_enable_video(monkeypatch)
|
|
task = await _seed_feature_draft(db_session)
|
|
client = _StubClient()
|
|
with (
|
|
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
patch(
|
|
"roboco.services.video_engine.get_video_engine",
|
|
side_effect=RuntimeError("video-engine boom"),
|
|
),
|
|
):
|
|
result = await _svc(db_session).approve(_id(task))
|
|
assert result is not None
|
|
assert result.status == "posted"
|
|
await db_session.refresh(task)
|
|
assert task.status == TS.COMPLETED
|