Files
roboco/tests/unit/services/test_x_post_service.py
T
f0b6390189 feat: Social page — aggregated post queues + X/video history (#345)
* feat(api): x/video post history endpoints

Approved or rejected drafts vanished from both queues permanently --
the listers exclude terminal statuses and no history surface existed,
so a posted tweet or video was only findable in the raw task list.
GET /x/posts/history and GET /video/posts/history (CEO-gated, bounded)
return acted-on drafts newest-first with the posted platform ids and
reject reasons from the draft markers. Route tests assert by identity,
not emptiness: approve/reject commits the whole session, so prior
tests' rows legitimately persist in the shared test DB.

* feat(panel): Social page aggregating post queues and history

New dashboard page composing the X and video post queues with one
unified history section beneath them -- both platforms interleaved
newest-first, kind and outcome badges, posted X ids linking to the
live tweet, reject reasons shown. The command center's two full queue
cards become a compact pending-counts card linking to the page, so the
queues have one home instead of duplicated surfaces.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-09 00:44:51 +02:00

466 lines
17 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.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
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 needs zero service changes: it rides the
same generic approve path as x_post/x_reply."""
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