mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
313 lines
11 KiB
Python
313 lines
11 KiB
Python
"""X engine route coverage — CEO-only list/approve/reject + credentials."""
|
|
|
|
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.x import router as x_router
|
|
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
|
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.task import X_POST_SOURCE
|
|
from roboco.services.x_client import XClient, XMention, XPostResult
|
|
from roboco.services.x_post_service import XPostService
|
|
|
|
HISTORY_LIMIT = 2
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import AsyncIterator
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
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) -> TaskTable:
|
|
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="X post: release v0.17.0",
|
|
description="draft body",
|
|
acceptance_criteria=["CEO approves or rejects"],
|
|
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=X_POST_SOURCE,
|
|
confirmed_by_human=False,
|
|
)
|
|
session.add(task)
|
|
await session.flush()
|
|
markers.set_x_draft_body(task, "draft body")
|
|
markers.set_x_release_version(task, "0.17.0")
|
|
await session.flush()
|
|
return task
|
|
|
|
|
|
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
|
|
app = FastAPI()
|
|
app.include_router(x_router, prefix="/api/x")
|
|
|
|
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()
|
|
|
|
|
|
class _StubClient(XClient):
|
|
@property
|
|
def configured(self) -> bool:
|
|
return True
|
|
|
|
async def post_tweet(self, text: str) -> XPostResult:
|
|
_ = text
|
|
return XPostResult(posted=True, tweet_id="42", detail="posted")
|
|
|
|
async def fetch_mentions(
|
|
self, since_id: str | None, max_results: int
|
|
) -> list[XMention]:
|
|
_ = (since_id, max_results)
|
|
return []
|
|
|
|
|
|
@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/x/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]["body"] == "draft body"
|
|
assert body[0]["release_version"] == "0.17.0"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_posts_and_returns_tweet_id(
|
|
db_session: AsyncSession, ceo_client: AsyncClient
|
|
) -> None:
|
|
task = await _seed_draft(db_session)
|
|
with (
|
|
patch(
|
|
"roboco.services.x_post_service.build_x_client",
|
|
return_value=_StubClient(),
|
|
),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
):
|
|
resp = await ceo_client.post(f"/api/x/posts/{task.id}/approve", json={})
|
|
assert resp.status_code == HTTPStatus.OK
|
|
body = resp.json()
|
|
assert body["status"] == "posted"
|
|
assert body["tweet_id"] == "42"
|
|
await db_session.refresh(task)
|
|
assert task.status == TaskStatus.COMPLETED
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_edited_body_over_limit_is_400(
|
|
db_session: AsyncSession, ceo_client: AsyncClient
|
|
) -> None:
|
|
task = await _seed_draft(db_session)
|
|
resp = await ceo_client.post(
|
|
f"/api/x/posts/{task.id}/approve", json={"edited_body": "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/x/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/x/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_history_returns_posted_and_rejected_newest_first(
|
|
db_session: AsyncSession, ceo_client: AsyncClient
|
|
) -> None:
|
|
rejected = await _seed_draft(db_session)
|
|
await ceo_client.post(
|
|
f"/api/x/posts/{rejected.id}/reject", json={"reason": "off-brand tone"}
|
|
)
|
|
posted = await _seed_draft(db_session)
|
|
with (
|
|
patch(
|
|
"roboco.services.x_post_service.build_x_client",
|
|
return_value=_StubClient(),
|
|
),
|
|
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
|
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
|
):
|
|
await ceo_client.post(f"/api/x/posts/{posted.id}/approve", json={})
|
|
|
|
resp = await ceo_client.get("/api/x/posts/history")
|
|
assert resp.status_code == HTTPStatus.OK
|
|
body = resp.json()
|
|
ids = [row["task_id"] for row in body]
|
|
assert str(posted.id) in ids
|
|
assert str(rejected.id) in ids
|
|
assert ids.index(str(posted.id)) < ids.index(str(rejected.id))
|
|
posted_row = next(row for row in body if row["task_id"] == str(posted.id))
|
|
assert posted_row["status"] == "completed"
|
|
assert posted_row["tweet_id"] == "42"
|
|
rejected_row = next(row for row in body if row["task_id"] == str(rejected.id))
|
|
assert rejected_row["status"] == "cancelled"
|
|
assert rejected_row["reject_reason"] == "off-brand tone"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_history_excludes_open_drafts(
|
|
db_session: AsyncSession, ceo_client: AsyncClient
|
|
) -> None:
|
|
"""Every approve/reject route in this file commits durably (the route
|
|
always calls db.commit()), so other tests' posted/rejected rows persist
|
|
in this shared-DB test session — history is never provably empty. Assert
|
|
identity instead: THIS still-open draft must not appear."""
|
|
open_task = await _seed_draft(db_session)
|
|
resp = await ceo_client.get("/api/x/posts/history")
|
|
assert resp.status_code == HTTPStatus.OK
|
|
ids = [row["task_id"] for row in resp.json()]
|
|
assert str(open_task.id) not in ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_history_respects_limit(
|
|
db_session: AsyncSession, ceo_client: AsyncClient
|
|
) -> None:
|
|
for _ in range(3):
|
|
t = await _seed_draft(db_session)
|
|
await ceo_client.post(
|
|
f"/api/x/posts/{t.id}/reject", json={"reason": "not relevant"}
|
|
)
|
|
resp = await ceo_client.get("/api/x/posts/history", params={"limit": HISTORY_LIMIT})
|
|
assert resp.status_code == HTTPStatus.OK
|
|
assert len(resp.json()) == HISTORY_LIMIT
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_history_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
|
|
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
resp = await client.get("/api/x/posts/history")
|
|
assert resp.status_code == HTTPStatus.FORBIDDEN
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_credentials_default_is_unset(ceo_client: AsyncClient) -> None:
|
|
resp = await ceo_client.get("/api/x/credentials")
|
|
assert resp.status_code == HTTPStatus.OK
|
|
assert resp.json()["has_credentials"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_credentials_reports_status_never_plaintext(
|
|
ceo_client: AsyncClient,
|
|
) -> None:
|
|
resp = await ceo_client.post(
|
|
"/api/x/credentials",
|
|
json={
|
|
"api_key": "secret-key-value",
|
|
"api_secret": "secret-apisecret-value",
|
|
"access_token": "secret-token-value",
|
|
"access_token_secret": "secret-tokensecret-value",
|
|
},
|
|
)
|
|
assert resp.status_code == HTTPStatus.OK
|
|
assert resp.json() == {"has_credentials": True}
|
|
assert "secret-key-value" not in resp.text
|
|
assert "secret-apisecret-value" not in resp.text
|
|
assert "secret-token-value" not in resp.text
|
|
assert "secret-tokensecret-value" not in resp.text
|
|
|
|
status_resp = await ceo_client.get("/api/x/credentials")
|
|
assert status_resp.json()["has_credentials"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
|
|
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:
|
|
list_resp = await client.get("/api/x/posts")
|
|
creds_resp = await client.get("/api/x/credentials")
|
|
assert list_resp.status_code == HTTPStatus.FORBIDDEN
|
|
assert creds_resp.status_code == HTTPStatus.FORBIDDEN
|
|
app.dependency_overrides.clear()
|