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();
});
});