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 {ui}; } 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()); 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()); 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()); 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()); 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()); 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()); await waitFor(() => expect(listPosts).toHaveBeenCalled()); expect(container).toBeEmptyDOMElement(); }); });