mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
162 lines
5.1 KiB
TypeScript
162 lines
5.1 KiB
TypeScript
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 { XPost } from "@/lib/api/x";
|
|
|
|
const { resolveApproveRef } = vi.hoisted(() => ({
|
|
resolveApproveRef: { current: null as null | ((v: unknown) => void) },
|
|
}));
|
|
|
|
const { listPosts, approve, reject } = vi.hoisted(() => ({
|
|
listPosts: vi.fn(
|
|
async () =>
|
|
[
|
|
{
|
|
task_id: "x-1",
|
|
source: "x_post",
|
|
title: "X post: release v0.17.0",
|
|
status: "pending",
|
|
body: "RoboCo v0.17.0 just shipped!",
|
|
char_count: 28,
|
|
release_version: "0.17.0",
|
|
},
|
|
{
|
|
task_id: "x-2",
|
|
source: "x_reply",
|
|
title: "X reply: mention m1",
|
|
status: "pending",
|
|
body: "Thanks for the shoutout!",
|
|
char_count: 24,
|
|
mention: { id: "m1", author_id: "a1", text: "great work @roboco" },
|
|
},
|
|
] as XPost[],
|
|
),
|
|
// Deferred so the test can freeze the approve mid-flight.
|
|
approve: vi.fn(
|
|
() =>
|
|
new Promise((r) => {
|
|
resolveApproveRef.current = r as (v: unknown) => void;
|
|
}),
|
|
),
|
|
reject: vi.fn(async () => ({})),
|
|
}));
|
|
|
|
vi.mock("@/lib/api", () => ({ xApi: { listPosts, approve, reject } }));
|
|
|
|
import { XPostQueue } from "../x-post-queue";
|
|
|
|
function withQueryClient(ui: ReactNode) {
|
|
const client = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
|
}
|
|
|
|
describe("XPostQueue", () => {
|
|
beforeEach(() => {
|
|
listPosts.mockClear();
|
|
approve.mockClear();
|
|
reject.mockClear();
|
|
resolveApproveRef.current = null;
|
|
});
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("renders both a release post and a mention reply draft", async () => {
|
|
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();
|
|
});
|
|
|
|
it("renders a feature-spotlight draft with its own label and badge", async () => {
|
|
listPosts.mockResolvedValueOnce([
|
|
{
|
|
task_id: "x-3",
|
|
source: "x_feature",
|
|
title: "X feature spotlight: playbooks",
|
|
status: "pending",
|
|
body: "Did you know RoboCo curates playbooks from real task runs?",
|
|
char_count: 60,
|
|
feature: { slug: "playbooks", title: "Playbook curation" },
|
|
},
|
|
] as XPost[]);
|
|
|
|
render(withQueryClient(<XPostQueue />));
|
|
|
|
expect(await screen.findByText("Feature spotlight")).toBeInTheDocument();
|
|
expect(screen.queryByText("Mention reply")).not.toBeInTheDocument();
|
|
expect(screen.getByText(/Playbook curation/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("disables only the row being approved, not every row's Approve", async () => {
|
|
render(withQueryClient(<XPostQueue />));
|
|
|
|
const approveButtons = await screen.findAllByRole("button", {
|
|
name: /Approve/,
|
|
});
|
|
expect(approveButtons).toHaveLength(2);
|
|
expect(approveButtons[0]).not.toBeDisabled();
|
|
expect(approveButtons[1]).not.toBeDisabled();
|
|
|
|
fireEvent.click(approveButtons[0]);
|
|
await waitFor(() =>
|
|
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",
|
|
});
|
|
await waitFor(() => expect(approveButtons[0]).not.toBeDisabled());
|
|
});
|
|
|
|
it("disables Approve when the edited body exceeds 280 characters", async () => {
|
|
render(withQueryClient(<XPostQueue />));
|
|
const textarea = await screen.findByDisplayValue(
|
|
"RoboCo v0.17.0 just shipped!",
|
|
);
|
|
fireEvent.change(textarea, { target: { value: "x".repeat(281) } });
|
|
|
|
const approveButtons = await screen.findAllByRole("button", {
|
|
name: /Approve/,
|
|
});
|
|
expect(approveButtons[0]).toBeDisabled();
|
|
expect(screen.getByText("281/280")).toBeInTheDocument();
|
|
});
|
|
|
|
it("rejects a draft with a reason", async () => {
|
|
render(withQueryClient(<XPostQueue />));
|
|
const rejectButtons = await screen.findAllByRole("button", {
|
|
name: "Reject",
|
|
});
|
|
fireEvent.click(rejectButtons[1]);
|
|
|
|
const reasonBox = await screen.findByLabelText("Reason");
|
|
fireEvent.change(reasonBox, { target: { value: "not relevant" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
|
|
|
|
await waitFor(() =>
|
|
expect(reject).toHaveBeenCalledWith("x-2", "not relevant"),
|
|
);
|
|
});
|
|
|
|
it("renders nothing when the queue is empty", async () => {
|
|
listPosts.mockResolvedValueOnce([]);
|
|
const { container } = render(withQueryClient(<XPostQueue />));
|
|
await waitFor(() => expect(listPosts).toHaveBeenCalled());
|
|
expect(container).toBeEmptyDOMElement();
|
|
});
|
|
});
|