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,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());
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user