mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user