Files
roboco/tests/integration/test_x_routes.py
T

238 lines
7.9 KiB
Python
Raw Normal View History

"""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
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_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()