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>
This commit is contained in:
Renzo F
2026-07-09 00:44:51 +02:00
committed by GitHub
co-authored by Renn F
parent 5886336259
commit f0b6390189
26 changed files with 1289 additions and 30 deletions
+94
View File
@@ -44,6 +44,7 @@ SLUG = "roboco-video-route-test"
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
UX_DEV_1_UUID = _foundation.AGENTS["ux-dev-1"].uuid
UX_DEV_2_UUID = _foundation.AGENTS["ux-dev-2"].uuid
HISTORY_LIMIT = 2
async def _seed(session: AsyncSession) -> None:
@@ -438,6 +439,99 @@ async def test_approve_with_credentials_posts_via_the_real_poster_wiring(
await db_session.commit()
@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, platforms=["x"])
with _LOCKED[0], _LOCKED[1]:
await ceo_client.post(
f"/api/video/posts/{rejected.id}/reject",
json={"reason": "wrong occasion"},
)
posted = await _seed_draft(db_session, platforms=["x"])
creds_svc = get_x_credentials_service(db_session)
await creds_svc.set_credentials(
api_key="ak", api_secret="as", access_token="at", access_token_secret="ats"
)
try:
with (
_LOCKED[0],
_LOCKED[1],
patch.object(
LiveXVideoPoster,
"post_video",
AsyncMock(
return_value=XVideoPostResult(
posted=True, video_id="xid42", detail="posted"
)
),
),
):
await ceo_client.post(f"/api/video/posts/{posted.id}/approve", json={})
resp = await ceo_client.get("/api/video/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["posted"] == {"x": "xid42"}
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"] == "wrong occasion"
finally:
await creds_svc.set_credentials(
api_key="", api_secret="", access_token="", access_token_secret=""
)
await db_session.commit()
@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/video/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)
with _LOCKED[0], _LOCKED[1]:
await ceo_client.post(
f"/api/video/posts/{t.id}/reject", json={"reason": "not relevant"}
)
resp = await ceo_client.get(
"/api/video/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/video/posts/history")
assert resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_approve_edited_x_caption_over_limit_is_422(
db_session: AsyncSession, ceo_client: AsyncClient
+75
View File
@@ -22,6 +22,8 @@ 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
@@ -193,6 +195,79 @@ async def test_reject_cancels_and_records_reason(
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")