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
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { XPostHistoryEntry } from "@/lib/api/x";
import type { VideoPostHistoryEntry } from "@/lib/api/video";
// The queues have their own dedicated test files (x-post-queue.test.tsx,
// video-post-queue.test.tsx) — stub them here so this page test only checks
// composition + the unified history, not their internals.
vi.mock("@/components/dashboard/x-post-queue", () => ({
XPostQueue: () => <div>XPostQueueStub</div>,
}));
vi.mock("@/components/dashboard/video-post-queue", () => ({
VideoPostQueue: () => <div>VideoPostQueueStub</div>,
}));
const { listXHistory, listVideoHistory } = vi.hoisted(() => ({
listXHistory: vi.fn(
async () =>
[
{
task_id: "x-9",
source: "x_post",
title: "X post: release v0.16.0",
status: "completed",
body: "RoboCo v0.16.0 shipped!",
char_count: 23,
tweet_id: "555",
acted_at: "2026-07-01T00:00:00Z",
},
] as XPostHistoryEntry[],
),
listVideoHistory: vi.fn(
async () =>
[
{
task_id: "v-9",
source: "video_post",
title: "Video: spotlight",
status: "cancelled",
occasion: "spotlight",
script: "script",
platforms: ["tiktok"],
posted: {},
reject_reason: "off-brand",
acted_at: "2026-06-30T00:00:00Z",
},
] as VideoPostHistoryEntry[],
),
}));
vi.mock("@/lib/api", () => ({
xApi: { listHistory: listXHistory },
videoApi: { listHistory: listVideoHistory },
}));
import SocialPage from "../page";
function withQueryClient(
client = new QueryClient({
defaultOptions: { queries: { retry: false } },
}),
) {
return { client };
}
describe("SocialPage", () => {
beforeEach(() => {
listXHistory.mockClear();
listVideoHistory.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders the page title and both queues", () => {
const { client } = withQueryClient();
render(
<QueryClientProvider client={client}>
<SocialPage />
</QueryClientProvider>,
);
expect(screen.getByRole("heading", { name: "Social" })).toBeInTheDocument();
expect(screen.getByText("XPostQueueStub")).toBeInTheDocument();
expect(screen.getByText("VideoPostQueueStub")).toBeInTheDocument();
});
it("renders the unified history collapsed by default, then shows posted + rejected rows on expand", async () => {
const { client } = withQueryClient();
render(
<QueryClientProvider client={client}>
<SocialPage />
</QueryClientProvider>,
);
expect(screen.getByText("History")).toBeInTheDocument();
expect(listXHistory).not.toHaveBeenCalled();
expect(listVideoHistory).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: /Show history/ }));
await waitFor(() => expect(listXHistory).toHaveBeenCalled());
await waitFor(() => expect(listVideoHistory).toHaveBeenCalled());
expect(await screen.findByText("Posted")).toBeInTheDocument();
expect(screen.getByText("Rejected")).toBeInTheDocument();
expect(screen.getByText(/off-brand/)).toBeInTheDocument();
});
});
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { XPostQueue } from "@/components/dashboard/x-post-queue";
import { VideoPostQueue } from "@/components/dashboard/video-post-queue";
import { SocialHistorySection } from "@/components/dashboard/social-history-section";
export default function SocialPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Social</h1>
<p className="text-muted-foreground">
X and video drafts awaiting your approval, plus everything already
posted or rejected.
</p>
</div>
<XPostQueue />
<VideoPostQueue />
<SocialHistorySection />
</div>
);
}
@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
// Shallow-render the composition: mock every hook + child component so this
// test only checks that the social summary card replaced the two full X/
// video queues — each child's own behavior is covered by its own test file.
vi.mock("@/hooks/use-dashboard", () => ({
useCeoOverview: () => ({
data: undefined,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
useAuditorFlags: () => ({
data: undefined,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
useRecentActivity: () => ({
data: undefined,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/use-tasks", () => ({
useTasks: () => ({
data: undefined,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
vi.mock("../team-health-cards", () => ({
TeamHealthCards: () => <div>TeamHealthCardsStub</div>,
}));
vi.mock("../key-metrics-panel", () => ({
KeyMetricsPanel: () => <div>KeyMetricsPanelStub</div>,
}));
vi.mock("../auditor-alerts-panel", () => ({
AuditorAlertsPanel: () => <div>AuditorAlertsPanelStub</div>,
}));
vi.mock("../active-blockers-panel", () => ({
ActiveBlockersPanel: () => <div>ActiveBlockersPanelStub</div>,
}));
vi.mock("../recent-activity-feed", () => ({
RecentActivityFeed: () => <div>RecentActivityFeedStub</div>,
}));
vi.mock("../quick-actions-bar", () => ({
QuickActionsBar: () => <div>QuickActionsBarStub</div>,
}));
vi.mock("../ceo-approval-queue", () => ({
CeoApprovalQueue: () => <div>CeoApprovalQueueStub</div>,
}));
vi.mock("../pr-review-queue", () => ({
PrReviewQueue: () => <div>PrReviewQueueStub</div>,
}));
vi.mock("../release-proposal-card", () => ({
ReleaseProposalCard: () => <div>ReleaseProposalCardStub</div>,
}));
vi.mock("../playbook-review-queue", () => ({
PlaybookReviewQueue: () => <div>PlaybookReviewQueueStub</div>,
}));
vi.mock("../social-summary-card", () => ({
SocialSummaryCard: () => <div>SocialSummaryCardStub</div>,
}));
vi.mock("../roadmap-review-queue", () => ({
RoadmapReviewQueue: () => <div>RoadmapReviewQueueStub</div>,
}));
vi.mock("../strategy-signals-panel", () => ({
StrategySignalsPanel: () => <div>StrategySignalsPanelStub</div>,
}));
vi.mock("../usage-overview-panel", () => ({
UsageOverviewPanel: () => <div>UsageOverviewPanelStub</div>,
}));
vi.mock("../scorecard-overview-panel", () => ({
ScorecardOverviewPanel: () => <div>ScorecardOverviewPanelStub</div>,
}));
import { CommandCenter } from "../command-center";
describe("CommandCenter", () => {
it("renders the social summary card instead of the full X/video queues", () => {
render(<CommandCenter />);
expect(screen.getByText("SocialSummaryCardStub")).toBeInTheDocument();
expect(screen.queryByText("XPostQueueStub")).not.toBeInTheDocument();
expect(screen.queryByText("VideoPostQueueStub")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { XPostHistoryEntry } from "@/lib/api/x";
import type { VideoPostHistoryEntry } from "@/lib/api/video";
const { listXHistory, listVideoHistory } = vi.hoisted(() => ({
listXHistory: vi.fn(
async () =>
[
{
task_id: "x-9",
source: "x_post",
title: "X post: release v0.16.0",
status: "completed",
body: "RoboCo v0.16.0 shipped!",
char_count: 23,
tweet_id: "555",
acted_at: "2026-07-01T00:00:00Z",
},
{
task_id: "x-10",
source: "x_reply",
title: "X reply: mention m2",
status: "cancelled",
body: "Not our voice",
char_count: 13,
reject_reason: "tone mismatch",
acted_at: "2026-06-29T00:00:00Z",
},
] as XPostHistoryEntry[],
),
listVideoHistory: vi.fn(
async () =>
[
{
task_id: "v-9",
source: "video_post",
title: "Video: release v0.18.0",
status: "completed",
occasion: "release",
script: "old script",
platforms: ["x", "tiktok"],
posted: { x: "xid777", tiktok: "tt-abc" },
acted_at: "2026-06-30T00:00:00Z",
},
] as VideoPostHistoryEntry[],
),
}));
vi.mock("@/lib/api", () => ({
xApi: { listHistory: listXHistory },
videoApi: { listHistory: listVideoHistory },
}));
import { SocialHistorySection } from "../social-history-section";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("SocialHistorySection", () => {
beforeEach(() => {
listXHistory.mockClear();
listVideoHistory.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("is collapsed by default and fetches neither source", async () => {
render(withQueryClient(<SocialHistorySection />));
expect(await screen.findByText("History")).toBeInTheDocument();
expect(listXHistory).not.toHaveBeenCalled();
expect(listVideoHistory).not.toHaveBeenCalled();
expect(screen.queryByText("tone mismatch")).not.toBeInTheDocument();
});
it("lazy-fetches both sources on expand and interleaves them newest-first", async () => {
render(withQueryClient(<SocialHistorySection />));
fireEvent.click(screen.getByRole("button", { name: /Show history/ }));
await waitFor(() => expect(listXHistory).toHaveBeenCalledWith(50));
await waitFor(() => expect(listVideoHistory).toHaveBeenCalledWith(50));
expect(await screen.findByText("X post")).toBeInTheDocument();
expect(screen.getByText("Video")).toBeInTheDocument();
expect(screen.getByText("X reply")).toBeInTheDocument();
// Newest acted_at first: x-9 (07-01) > video v-9 (06-30) > x-10 (06-29).
const rows = screen.getAllByText(/^(X post|X reply|Video)$/);
expect(rows.map((el) => el.textContent)).toEqual([
"X post",
"Video",
"X reply",
]);
});
it("renders a posted X row linking the tweet and a rejected row with the reason", async () => {
render(withQueryClient(<SocialHistorySection />));
fireEvent.click(screen.getByRole("button", { name: /Show history/ }));
await screen.findByText("X post");
const link = screen.getByRole("link", { name: "View on X" });
expect(link).toHaveAttribute("href", "https://x.com/i/status/555");
expect(screen.getByText(/tone mismatch/)).toBeInTheDocument();
});
it("renders a posted video row with both platform ids (X links out, TikTok shows the raw id)", async () => {
render(withQueryClient(<SocialHistorySection />));
fireEvent.click(screen.getByRole("button", { name: /Show history/ }));
await screen.findByText("Video");
const videoLink = screen.getByRole("link", { name: /xid777/ });
expect(videoLink).toHaveAttribute("href", "https://x.com/i/status/xid777");
expect(screen.getByText(/tt-abc/)).toBeInTheDocument();
});
it("shows an empty state when neither source has acted-on drafts", async () => {
listXHistory.mockResolvedValueOnce([]);
listVideoHistory.mockResolvedValueOnce([]);
render(withQueryClient(<SocialHistorySection />));
fireEvent.click(screen.getByRole("button", { name: /Show history/ }));
expect(
await screen.findByText("No acted-on drafts yet."),
).toBeInTheDocument();
});
});
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { XPost } from "@/lib/api/x";
import type { VideoPost } from "@/lib/api/video";
const { listXPosts, listVideoPosts } = vi.hoisted(() => ({
listXPosts: vi.fn(
async () =>
[
{
task_id: "x-1",
source: "x_post",
title: "X post",
status: "pending",
body: "body",
char_count: 4,
},
{
task_id: "x-2",
source: "x_reply",
title: "X reply",
status: "pending",
body: "body2",
char_count: 5,
},
] as XPost[],
),
listVideoPosts: vi.fn(
async () =>
[
{
task_id: "v-1",
source: "video_post",
title: "Video",
status: "pending",
occasion: "release",
script: "script",
platforms: ["x"],
},
] as VideoPost[],
),
}));
vi.mock("@/lib/api", () => ({
xApi: { listPosts: listXPosts },
videoApi: { listPosts: listVideoPosts },
}));
import { SocialSummaryCard } from "../social-summary-card";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("SocialSummaryCard", () => {
beforeEach(() => {
listXPosts.mockClear();
listVideoPosts.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("shows the pending draft counts for both X and video and a total badge", async () => {
render(withQueryClient(<SocialSummaryCard />));
expect(await screen.findByText("2 X drafts")).toBeInTheDocument();
expect(screen.getByText("1 video draft")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
});
it("links to /social", async () => {
render(withQueryClient(<SocialSummaryCard />));
await screen.findByText("2 X drafts");
const link = screen.getByRole("link", { name: /Open Social/ });
expect(link).toHaveAttribute("href", "/social");
});
it("renders zero counts and no total badge when both queues are empty", async () => {
listXPosts.mockResolvedValueOnce([]);
listVideoPosts.mockResolvedValueOnce([]);
render(withQueryClient(<SocialSummaryCard />));
expect(await screen.findByText("0 X drafts")).toBeInTheDocument();
expect(screen.getByText("0 video drafts")).toBeInTheDocument();
expect(screen.queryByText("0")).not.toBeInTheDocument();
});
});
@@ -68,7 +68,9 @@ describe("XPostQueue", () => {
render(withQueryClient(<XPostQueue />));
expect(await screen.findByText("Release post")).toBeInTheDocument();
expect(await screen.findByText("Mention reply")).toBeInTheDocument();
expect(screen.getByDisplayValue("RoboCo v0.17.0 just shipped!")).toBeInTheDocument();
expect(
screen.getByDisplayValue("RoboCo v0.17.0 just shipped!"),
).toBeInTheDocument();
});
it("renders a feature-spotlight draft with its own label and badge", async () => {
@@ -103,13 +105,20 @@ describe("XPostQueue", () => {
fireEvent.click(approveButtons[0]);
await waitFor(() =>
expect(approve).toHaveBeenCalledWith("x-1", "RoboCo v0.17.0 just shipped!"),
expect(approve).toHaveBeenCalledWith(
"x-1",
"RoboCo v0.17.0 just shipped!",
),
);
await waitFor(() => expect(approveButtons[0]).toBeDisabled());
expect(approveButtons[1]).not.toBeDisabled();
resolveApproveRef.current?.({ status: "posted", tweet_id: "1", detail: "ok" });
resolveApproveRef.current?.({
status: "posted",
tweet_id: "1",
detail: "ok",
});
await waitFor(() => expect(approveButtons[0]).not.toBeDisabled());
});
@@ -16,8 +16,7 @@ import { CeoApprovalQueue } from "./ceo-approval-queue";
import { PrReviewQueue } from "./pr-review-queue";
import { ReleaseProposalCard } from "./release-proposal-card";
import { PlaybookReviewQueue } from "./playbook-review-queue";
import { XPostQueue } from "./x-post-queue";
import { VideoPostQueue } from "./video-post-queue";
import { SocialSummaryCard } from "./social-summary-card";
import { RoadmapReviewQueue } from "./roadmap-review-queue";
import { StrategySignalsPanel } from "./strategy-signals-panel";
import type { Activity } from "./activity-item";
@@ -121,14 +120,10 @@ export function CommandCenter() {
<PlaybookReviewQueue />
</div>
{/* X post/reply queue (hidden when no drafts) */}
{/* Social (X + video) summary — the full queues + unified history live
on /social, avoiding a duplicated surface here. */}
<div className="order-4 md:order-none">
<XPostQueue />
</div>
{/* Video post queue (always visible — carries the on-demand request action) */}
<div className="order-4 md:order-none">
<VideoPostQueue />
<SocialSummaryCard />
</div>
{/* Board roadmap queue (hidden when no cycle authored) */}
@@ -0,0 +1,208 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { xApi, videoApi } from "@/lib/api";
import type { XPostHistoryEntry } from "@/lib/api/x";
import type { VideoPostHistoryEntry } from "@/lib/api/video";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
AtSign,
ChevronDown,
Film,
History,
Rocket,
Sparkles,
} from "lucide-react";
const HISTORY_LIMIT = 50;
const PLATFORM_LABELS: Record<string, string> = { x: "X", tiktok: "TikTok" };
type UnifiedRow =
| { kind: "x"; entry: XPostHistoryEntry }
| { kind: "video"; entry: VideoPostHistoryEntry };
function xKindMeta(source: XPostHistoryEntry["source"]) {
if (source === "x_post") return { label: "X post", icon: Rocket };
if (source === "x_feature")
return { label: "Feature spotlight", icon: Sparkles };
return { label: "X reply", icon: AtSign };
}
// One unified row: X entries link the posted tweet / show the reject reason;
// video entries list each platform's posted id (X id links out, TikTok's
// inbox upload has no public URL so just the raw id is shown).
function UnifiedHistoryRow({ row }: { row: UnifiedRow }) {
const posted = row.entry.status === "completed";
const statusBadge = posted ? (
<Badge className="bg-green-600 hover:bg-green-600">Posted</Badge>
) : (
<Badge variant="destructive">Rejected</Badge>
);
const timestamp = (
<span className="ml-auto text-xs text-muted-foreground">
{new Date(row.entry.acted_at).toLocaleString()}
</span>
);
if (row.kind === "x") {
const meta = xKindMeta(row.entry.source);
return (
<div className="rounded-lg border p-3 text-sm">
<div className="mb-1 flex flex-wrap items-center gap-2">
<meta.icon className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{meta.label}</span>
{statusBadge}
{timestamp}
</div>
<p className="line-clamp-2 text-muted-foreground">{row.entry.body}</p>
{posted && row.entry.tweet_id && (
<a
href={`https://x.com/i/status/${row.entry.tweet_id}`}
target="_blank"
rel="noreferrer"
className="text-xs text-primary underline"
>
View on X
</a>
)}
{!posted && row.entry.reject_reason && (
<p className="mt-1 text-xs text-muted-foreground">
Reason: {row.entry.reject_reason}
</p>
)}
</div>
);
}
const platformIds = Object.entries(row.entry.posted);
return (
<div className="rounded-lg border p-3 text-sm">
<div className="mb-1 flex flex-wrap items-center gap-2">
<Film className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Video</span>
{row.entry.occasion && (
<Badge variant="outline">{row.entry.occasion}</Badge>
)}
{statusBadge}
{timestamp}
</div>
{posted && platformIds.length > 0 && (
<div className="flex flex-wrap gap-3 text-xs">
{platformIds.map(([platform, id]) =>
platform === "x" ? (
<a
key={platform}
href={`https://x.com/i/status/${id}`}
target="_blank"
rel="noreferrer"
className="text-primary underline"
>
{PLATFORM_LABELS[platform] ?? platform}: {id}
</a>
) : (
<span key={platform} className="text-muted-foreground">
{PLATFORM_LABELS[platform] ?? platform}: {id}
</span>
),
)}
</div>
)}
{!posted && row.entry.reject_reason && (
<p className="mt-1 text-xs text-muted-foreground">
Reason: {row.entry.reject_reason}
</p>
)}
</div>
);
}
// Unified, collapsed-by-default history across X and video — merged client-
// side, newest-acted first. Lazy-fetched only once expanded (both sources in
// parallel); fixed 50-row-per-source limit (server default), no "show more".
export function SocialHistorySection({ className }: { className?: string }) {
const [open, setOpen] = useState(false);
const { data: xHistory, isLoading: xLoading } = useQuery({
queryKey: ["x", "posts", "history"],
queryFn: () => xApi.listHistory(HISTORY_LIMIT),
enabled: open,
});
const { data: videoHistory, isLoading: videoLoading } = useQuery({
queryKey: ["video", "posts", "history"],
queryFn: () => videoApi.listHistory(HISTORY_LIMIT),
enabled: open,
});
const isLoading = open && (xLoading || videoLoading);
const rows: UnifiedRow[] = [
...(xHistory ?? []).map((entry): UnifiedRow => ({ kind: "x", entry })),
...(videoHistory ?? []).map((entry): UnifiedRow => ({
kind: "video",
entry,
})),
].sort(
(a, b) =>
new Date(b.entry.acted_at).getTime() -
new Date(a.entry.acted_at).getTime(),
);
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
History
</CardTitle>
<CardDescription>
Every posted or rejected draft across X and video newest first.
</CardDescription>
</CardHeader>
<CardContent>
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-between"
>
<span>{open ? "Hide" : "Show"} history</span>
<ChevronDown
className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
{isLoading && (
<p className="text-sm text-muted-foreground">Loading...</p>
)}
{!isLoading && rows.length === 0 && (
<p className="text-sm text-muted-foreground">
No acted-on drafts yet.
</p>
)}
{rows.map((row) => (
<UnifiedHistoryRow
key={`${row.kind}-${row.entry.task_id}`}
row={row}
/>
))}
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
);
}
@@ -0,0 +1,67 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { xApi, videoApi } from "@/lib/api";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Share2 } from "lucide-react";
// Compact stand-in for the full X/video queues on the command center — the
// full queues (plus the unified history) moved to /social to avoid a
// duplicated surface. Reuses the same queries the queues themselves run
// (["x","posts"] / ["video","posts"]), so react-query dedups the fetch once
// /social's own queues are also mounted.
export function SocialSummaryCard({ className }: { className?: string }) {
const { data: xPosts } = useQuery({
queryKey: ["x", "posts"],
queryFn: () => xApi.listPosts(),
refetchInterval: 30000,
});
const { data: videoPosts } = useQuery({
queryKey: ["video", "posts"],
queryFn: () => videoApi.listPosts(),
refetchInterval: 30000,
});
const xCount = xPosts?.length ?? 0;
const videoCount = videoPosts?.length ?? 0;
const total = xCount + videoCount;
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Share2 className="h-5 w-5" />
Social
{total > 0 && <Badge variant="secondary">{total}</Badge>}
</CardTitle>
<CardDescription>
Held X and video drafts awaiting your approval.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-center justify-between gap-3">
<div className="flex gap-4 text-sm text-muted-foreground">
<span>
{xCount} X draft{xCount === 1 ? "" : "s"}
</span>
<span>
{videoCount} video draft{videoCount === 1 ? "" : "s"}
</span>
</div>
<Link href="/social" prefetch={false}>
<Button variant="outline" size="sm">
Open Social
</Button>
</Link>
</CardContent>
</Card>
);
}
@@ -374,7 +374,8 @@ function RequestVideoDialog({
// CEO queue for held video_post drafts (rendered clips from release/
// spotlight/on-demand triggers). Hidden while loading; shows an empty-state
// card (with the on-demand request action) when there are no drafts yet —
// mirrors XPostQueue.
// mirrors XPostQueue. Posted/rejected drafts move to the unified history on
// /social (SocialHistorySection).
export function VideoPostQueue({ className }: { className?: string }) {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<VideoPost | null>(null);
@@ -125,7 +125,8 @@ function XPostRow({
}
// CEO queue for held X drafts (release posts + mention replies). Hidden when
// empty (mirrors the release-proposal + playbook-review queues).
// empty (mirrors the release-proposal + playbook-review queues). Posted/
// rejected drafts move to the unified history on /social (SocialHistorySection).
export function XPostQueue({ className }: { className?: string }) {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<XPost | null>(null);
@@ -197,13 +198,12 @@ export function XPostQueue({ className }: { className?: string }) {
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
X Post Queue
<Rocket className="h-5 w-5" />X Post Queue
</CardTitle>
<CardDescription>
Drafted release announcements, feature spotlights, and mention
replies (if enabled) land here for you to edit, approve, or
reject. Nothing posts on its own.
replies (if enabled) land here for you to edit, approve, or reject.
Nothing posts on its own.
</CardDescription>
</CardHeader>
<CardContent>
@@ -222,8 +222,7 @@ export function XPostQueue({ className }: { className?: string }) {
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
X Post Queue
<Rocket className="h-5 w-5" />X Post Queue
<Badge variant="secondary">{posts.length}</Badge>
</CardTitle>
<CardDescription>
+2
View File
@@ -23,6 +23,7 @@ import {
Sparkles,
Building2,
Radio,
Share2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -32,6 +33,7 @@ export const navItems = [
// Dashboard
{ title: "Overview", href: "/overview", icon: LayoutDashboard },
{ title: "Business", href: "/business", icon: Building2 },
{ title: "Social", href: "/social", icon: Share2 },
// Work Management
{ title: "Tasks", href: "/tasks", icon: ListTodo },
+7 -1
View File
@@ -28,15 +28,21 @@ export type {
XPost,
XMentionRef,
XPostExecuteResult,
XPostHistoryEntry,
XCredentialsStatus,
} from "./x";
export { roadmapApi } from "./roadmap";
export type { RoadmapCycle, RoadmapItem, RoadmapItemActionResult } from "./roadmap";
export type {
RoadmapCycle,
RoadmapItem,
RoadmapItemActionResult,
} from "./roadmap";
export { videoApi, videoMediaUrl } from "./video";
export type {
VideoCut,
VideoPost,
VideoPostExecuteResult,
VideoPostHistoryEntry,
VideoRequestResult,
TikTokCredentialsStatus,
} from "./video";
+26 -4
View File
@@ -30,6 +30,22 @@ export interface VideoPostExecuteResult {
detail: string;
}
// One acted-on draft (posted or rejected) — the CEO's history view.
export interface VideoPostHistoryEntry {
task_id: string;
source: string; // "video_post"
title: string;
status: string; // "completed" | "cancelled"
occasion: string;
script: string;
platforms: string[];
x_caption?: string | null;
tiktok_caption?: string | null;
reject_reason?: string | null;
posted: Record<string, string>; // platform -> posted id
acted_at: string;
}
export interface VideoRequestResult {
status: string; // "opened" | "disabled" | "not_opened"
task_id?: string | null;
@@ -56,6 +72,15 @@ export const videoApi = {
const { data } = await api.get<VideoPost[]>("/video/posts");
return data;
},
// Posted or rejected drafts, newest-acted-first. Fixed default limit (50);
// no "load more" — pass a higher limit if the panel ever needs it.
listHistory: async (limit = 50): Promise<VideoPostHistoryEntry[]> => {
const { data } = await api.get<VideoPostHistoryEntry[]>(
"/video/posts/history",
{ params: { limit } },
);
return data;
},
// Fetches the rendered cut as a Blob via axios (carrying the auth headers
// a plain <video src> GET can't) so the caller can drive <video> off an
// object URL instead of pointing it at the route directly.
@@ -88,10 +113,7 @@ export const videoApi = {
brief: string;
platforms: string[];
}): Promise<VideoRequestResult> => {
const { data } = await api.post<VideoRequestResult>(
"/video/request",
body,
);
const { data } = await api.post<VideoRequestResult>("/video/request", body);
return data;
},
getCredentialsStatus: async (): Promise<TikTokCredentialsStatus> => {
+24
View File
@@ -37,6 +37,22 @@ export interface XPostExecuteResult {
detail: string;
}
// One acted-on draft (posted or rejected) — the CEO's history view.
export interface XPostHistoryEntry {
task_id: string;
source: "x_post" | "x_reply" | "x_feature";
title: string;
status: string; // "completed" | "cancelled"
body: string;
char_count: number;
release_version?: string | null;
mention?: XMentionRef | null;
feature?: XFeatureRef | null;
tweet_id?: string | null;
reject_reason?: string | null;
acted_at: string;
}
export interface XCredentialsStatus {
has_credentials: boolean;
}
@@ -46,6 +62,14 @@ export const xApi = {
const { data } = await api.get<XPost[]>("/x/posts");
return data;
},
// Posted or rejected drafts, newest-acted-first. Fixed default limit (50);
// no "load more" — pass a higher limit if the panel ever needs it.
listHistory: async (limit = 50): Promise<XPostHistoryEntry[]> => {
const { data } = await api.get<XPostHistoryEntry[]>("/x/posts/history", {
params: { limit },
});
return data;
},
approve: async (
taskId: string,
editedBody?: string,
+43 -2
View File
@@ -7,10 +7,10 @@ from __future__ import annotations
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, HTTPException, Query, status
from fastapi.responses import FileResponse, StreamingResponse
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
@@ -19,6 +19,7 @@ from roboco.api.schemas.video import (
TikTokCredentialsStatus,
VideoPostApproveRequest,
VideoPostExecuteResponse,
VideoPostHistoryResponse,
VideoPostRejectRequest,
VideoPostResponse,
VideoRequestBody,
@@ -165,6 +166,46 @@ async def list_video_posts(
return [_to_response(t) for t in tasks]
def _posted_ids(draft: dict[str, Any]) -> dict[str, str]:
"""Every ``{platform}_posted_id`` key stamped by approve, keyed by
platform (e.g. ``{"x": "..", "tiktok": ".."}``)."""
suffix = "_posted_id"
return {
k[: -len(suffix)]: str(v) for k, v in draft.items() if k.endswith(suffix) and v
}
def _to_history_response(task: TaskTable) -> VideoPostHistoryResponse:
draft = markers.get_video_draft(task) or {}
return VideoPostHistoryResponse(
task_id=str(task.id),
source=task.source,
title=task.title,
status=_status_value(task),
occasion=str(draft.get("occasion") or ""),
script=str(draft.get("script") or ""),
platforms=list(draft.get("platforms") or []),
x_caption=draft.get("x_caption"),
tiktok_caption=draft.get("tiktok_caption"),
reject_reason=markers.get_video_reject_reason(task),
posted=_posted_ids(draft),
acted_at=task.updated_at or task.created_at,
)
@router.get("/posts/history", response_model=list[VideoPostHistoryResponse])
async def list_video_post_history(
db: DbSession,
agent: CurrentAgentContext,
limit: int = Query(default=50, ge=1, le=200),
) -> list[VideoPostHistoryResponse]:
"""Posted or rejected video_post drafts, newest-acted-first, bounded by
`limit`."""
_require_ceo(agent)
tasks = await get_video_post_service(db).list_video_post_history(limit=limit)
return [_to_history_response(t) for t in tasks]
@router.get("/posts/{task_id}/media", response_model=None)
async def get_video_post_media(
task_id: UUID,
+34 -1
View File
@@ -5,7 +5,7 @@ credentials. CEO-only throughout. Nothing here posts except an explicit
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, HTTPException, Query, status
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
from roboco.api.schemas.x import (
@@ -15,6 +15,7 @@ from roboco.api.schemas.x import (
XMentionRefModel,
XPostApproveRequest,
XPostExecuteResponse,
XPostHistoryResponse,
XPostRejectRequest,
XPostResponse,
)
@@ -69,6 +70,38 @@ async def list_x_posts(
return [_to_response(t) for t in tasks]
def _to_history_response(task: "TaskTable") -> XPostHistoryResponse:
body = markers.get_x_draft_body(task) or task.description or ""
mention = markers.get_x_mention_ref(task)
feature = markers.get_x_feature_ref(task)
return XPostHistoryResponse(
task_id=str(task.id),
source=task.source,
title=task.title,
status=_status_value(task),
body=body,
char_count=len(body),
release_version=markers.get_x_release_version(task),
mention=XMentionRefModel(**mention) if mention else None,
feature=XFeatureRefModel(**feature) if feature else None,
tweet_id=markers.get_x_posted_tweet_id(task),
reject_reason=markers.get_x_reject_reason(task),
acted_at=task.updated_at or task.created_at,
)
@router.get("/posts/history", response_model=list[XPostHistoryResponse])
async def list_x_post_history(
db: DbSession,
agent: CurrentAgentContext,
limit: int = Query(default=50, ge=1, le=200),
) -> list[XPostHistoryResponse]:
"""Posted or rejected X drafts, newest-acted-first, bounded by `limit`."""
_require_ceo(agent)
tasks = await get_x_post_service(db).list_post_history(limit=limit)
return [_to_history_response(t) for t in tasks]
@router.post("/posts/{task_id}/approve", response_model=XPostExecuteResponse)
@guard_deco.rate_limit(requests=20, window=60)
@guard_deco.block_clouds()
+19 -1
View File
@@ -1,6 +1,6 @@
"""Schemas for the video engine's on-demand request + CEO approval surface."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
@@ -63,6 +63,24 @@ class VideoPostRejectRequest(BaseModel):
reason: str = Field(min_length=4)
class VideoPostHistoryResponse(BaseModel):
"""One acted-on video_post draft (posted or rejected) — the CEO's
history view."""
task_id: str
source: str # "video_post"
title: str
status: str # "completed" | "cancelled"
occasion: str
script: str
platforms: list[str]
x_caption: str | None = None
tiktok_caption: str | None = None
reject_reason: str | None = None
posted: dict[str, str] = Field(default_factory=dict) # platform -> posted id
acted_at: datetime
class TikTokCredentialsStatus(BaseModel):
"""Whether the four OAuth2 secrets are stored. Never the secrets themselves."""
+18 -1
View File
@@ -1,6 +1,6 @@
"""Schemas for the X (Twitter) engine's CEO surface."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
@@ -58,6 +58,23 @@ class XPostRejectRequest(BaseModel):
reason: str = Field(min_length=4)
class XPostHistoryResponse(BaseModel):
"""One acted-on X draft (posted or rejected) — the CEO's history view."""
task_id: str
source: str # "x_post" | "x_reply" | "x_feature"
title: str
status: str # "completed" | "cancelled"
body: str
char_count: int
release_version: str | None = None
mention: XMentionRefModel | None = None
feature: XFeatureRefModel | None = None
tweet_id: str | None = None
reject_reason: str | None = None
acted_at: datetime
class XCredentialsStatus(BaseModel):
"""Whether the four OAuth 1.0a secrets are stored. Never the secrets themselves."""
+31
View File
@@ -1497,6 +1497,37 @@ class TaskService(BaseService):
)
return list(result.scalars().all())
async def list_x_post_history(self, *, limit: int = 50) -> list[TaskTable]:
"""Acted-on X drafts (posted or rejected, both sources) — the panel
history basis. Newest-acted-first (updated_at, bumped by the
approve/reject status write) so the most recent decision reads
first; bounded by ``limit``."""
result = await self.session.execute(
select(TaskTable)
.where(
TaskTable.source.in_(X_SOURCES),
TaskTable.status.in_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
)
.order_by(TaskTable.updated_at.desc())
.limit(limit)
)
return list(result.scalars().all())
async def list_video_post_history(self, *, limit: int = 50) -> list[TaskTable]:
"""Acted-on video_post drafts (posted or rejected) — the panel
history basis, mirroring list_x_post_history. Newest-acted-first,
bounded by ``limit``."""
result = await self.session.execute(
select(TaskTable)
.where(
TaskTable.source == VIDEO_POST_SOURCE,
TaskTable.status.in_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
)
.order_by(TaskTable.updated_at.desc())
.limit(limit)
)
return list(result.scalars().all())
async def list_open_roadmap_cycles(self) -> list[TaskTable]:
"""Non-terminal board-roadmap exploration tasks — the one-open-cycle
dedup + panel-queue basis. Includes a cycle before AND after the
+5
View File
@@ -181,6 +181,11 @@ class VideoPostService(BaseService):
"""Every held video_post draft awaiting the CEO (panel queue basis)."""
return await get_task_service(self.session).list_open_video_post_drafts()
async def list_video_post_history(self, *, limit: int = 50) -> list[TaskTable]:
"""Acted-on video_post drafts (posted or rejected), newest-acted-
first — the panel history basis."""
return await get_task_service(self.session).list_video_post_history(limit=limit)
async def approve(
self,
task_id: UUID,
+5
View File
@@ -78,6 +78,11 @@ class XPostService(BaseService):
"""Every held X draft (both sources) awaiting the CEO."""
return await get_task_service(self.session).list_open_x_posts()
async def list_post_history(self, *, limit: int = 50) -> list[TaskTable]:
"""Acted-on X drafts (posted or rejected), newest-acted-first —
the panel history basis."""
return await get_task_service(self.session).list_x_post_history(limit=limit)
async def approve(
self, task_id: UUID, edited_body: str | None = None
) -> XPostExecuteResult | None:
+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")
@@ -577,6 +577,98 @@ async def test_list_held_video_posts_excludes_terminal(
assert rejected_task.id not in ids
@pytest.mark.asyncio
async def test_list_video_post_history_excludes_open_drafts(
db_session: AsyncSession, _test_database_url: str
) -> None:
"""approve() commits the whole session, so open_task's still-uncommitted
seed insert becomes durable too (same class of leak documented on
test_approve_partial_failure_keeps_task_open_and_persists_the_success)
clean it up explicitly so it doesn't pollute list_open_video_posts()/
list_open_video_post_drafts() assertions elsewhere in the suite."""
open_task = await _seed_video_post(db_session)
open_task_id = _id(open_task)
open_project_id = open_task.project_id
posted_task = await _seed_video_post(db_session, platforms=["x"])
svc = _svc(db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster())
try:
with _LOCKED[0], _LOCKED[1]:
await svc.approve(_id(posted_task))
history = await svc.list_video_post_history()
ids = {t.id for t in history}
assert posted_task.id in ids
assert open_task.id not in ids
finally:
cleanup, cleanup_engine = await _fresh_session(_test_database_url)
try:
await cleanup.execute(delete(TaskTable).where(TaskTable.id == open_task_id))
await cleanup.execute(
delete(ProjectTable).where(ProjectTable.id == open_project_id)
)
await cleanup.commit()
finally:
await _dispose(cleanup, cleanup_engine)
@pytest.mark.asyncio
async def test_list_video_post_history_newest_acted_first(
db_session: AsyncSession,
) -> None:
rejected_task = await _seed_video_post(db_session)
svc = _svc(db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster())
with _LOCKED[0], _LOCKED[1]:
await svc.reject(_id(rejected_task), "wrong occasion")
posted_task = await _seed_video_post(db_session, platforms=["x"])
with _LOCKED[0], _LOCKED[1]:
await svc.approve(_id(posted_task))
history = await svc.list_video_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_video_post_history_includes_marker_fields(
db_session: AsyncSession,
) -> None:
posted_task = await _seed_video_post(db_session, platforms=["x"])
svc = _svc(
db_session,
x_poster=_StubXPoster(video_id="xid9"),
tiktok_poster=_StubTikTokPoster(),
)
with _LOCKED[0], _LOCKED[1]:
await svc.approve(_id(posted_task))
rejected_task = await _seed_video_post(db_session)
with _LOCKED[0], _LOCKED[1]:
await svc.reject(_id(rejected_task), "off-brand")
history = await svc.list_video_post_history()
by_id = {t.id: t for t in history}
posted_draft = markers.get_video_draft(by_id[posted_task.id])
assert posted_draft is not None
assert posted_draft["x_posted_id"] == "xid9"
assert markers.get_video_reject_reason(by_id[rejected_task.id]) == "off-brand"
@pytest.mark.asyncio
async def test_list_video_post_history_respects_limit(
db_session: AsyncSession,
) -> None:
svc = _svc(db_session, x_poster=_StubXPoster(), tiktok_poster=_StubTikTokPoster())
tasks = []
for _ in range(3):
t = await _seed_video_post(db_session)
with _LOCKED[0], _LOCKED[1]:
await svc.reject(_id(t), "not relevant")
tasks.append(t)
history = await svc.list_video_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_commits_before_releasing_the_lock(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
@@ -42,6 +42,7 @@ if TYPE_CHECKING:
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
SECRETARY_UUID = _foundation.AGENTS["secretary-1"].uuid
ONE = 1
TWO = 2
class _StubClient(XClient):
@@ -364,6 +365,74 @@ async def test_reject_completed_raises(db_session: AsyncSession) -> None:
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,