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