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 { RoadmapCycle } from "@/lib/api/roadmap"; const { resolveApproveRef } = vi.hoisted(() => ({ resolveApproveRef: { current: null as null | ((v: unknown) => void) }, })); const { listCycles, approveItem, rejectItem } = vi.hoisted(() => ({ listCycles: vi.fn( async () => [ { task_id: "cycle-1", title: "Roadmap exploration cycle", status: "pending", goal: "Close onboarding friction", items: [ { id: "item-0", title: "Streamline signup", description: "Cut the signup form from 8 fields to 3", acceptance_criteria: ["signup takes < 30s"], project_slug: "backend-svc", team: "backend", priority: 2, rationale: "signup drop-off is the top funnel leak", status: "proposed", }, { id: "item-1", title: "Simplify pricing page", description: "Remove the three-tier confusion", acceptance_criteria: ["one clear CTA"], project_slug: "frontend-app", team: "frontend", priority: 3, rationale: "pricing page bounce rate is high", status: "proposed", }, ], }, ] as RoadmapCycle[], ), // Deferred so the test can freeze the approve mid-flight. approveItem: vi.fn( () => new Promise((r) => { resolveApproveRef.current = r as (v: unknown) => void; }), ), rejectItem: vi.fn(async () => ({})), })); vi.mock("@/lib/api", () => ({ roadmapApi: { listCycles, approveItem, rejectItem }, })); import { RoadmapReviewQueue } from "../roadmap-review-queue"; function withQueryClient(ui: ReactNode) { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); return {ui}; } describe("RoadmapReviewQueue", () => { beforeEach(() => { listCycles.mockClear(); approveItem.mockClear(); rejectItem.mockClear(); resolveApproveRef.current = null; }); afterEach(() => { vi.clearAllMocks(); }); it("renders the cycle goal and both item drafts", async () => { render(withQueryClient()); expect(await screen.findByText("Close onboarding friction")).toBeInTheDocument(); expect(screen.getByText("Streamline signup")).toBeInTheDocument(); expect(screen.getByText("Simplify pricing page")).toBeInTheDocument(); }); it("disables only the item 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(approveItem).toHaveBeenCalledWith("cycle-1", "item-0"), ); await waitFor(() => expect(approveButtons[0]).toBeDisabled()); expect(approveButtons[1]).not.toBeDisabled(); resolveApproveRef.current?.({ status: "approved", item_id: "item-0", materialized_task_id: "t-1", detail: "materialized into the backlog", }); await waitFor(() => expect(approveButtons[0]).not.toBeDisabled()); }); it("rejects an item 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 a priority" } }); fireEvent.click(screen.getByRole("button", { name: "Reject" })); await waitFor(() => expect(rejectItem).toHaveBeenCalledWith("cycle-1", "item-1", "not a priority"), ); }); it("renders nothing when there is no authored cycle", async () => { listCycles.mockResolvedValueOnce([]); const { container } = render(withQueryClient()); await waitFor(() => expect(listCycles).toHaveBeenCalled()); expect(container).toBeEmptyDOMElement(); }); });