mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[F085] reject submitting both project_id and product_id
validate() only checked 'at least one of project/product', so the dialog let both be submitted together. The server silently lets product_id win at routing and drops project_id, recording a misleading, never-used repo. Add a validator that refuses the ambiguous submit with a clear error. The at-least-one rule and the single-pick submit paths are unchanged.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const { mutateAsync } = vi.hoisted(() => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// useCreateTask: just needs a mutateAsync the test can assert on.
|
||||
vi.mock("@/hooks/use-tasks", () => ({
|
||||
useCreateTask: () => ({ mutateAsync, isPending: false }),
|
||||
}));
|
||||
|
||||
// useProducts: one product so the Product <Select> has a pickable option.
|
||||
vi.mock("@/hooks/use-products", () => ({
|
||||
useProducts: () => ({
|
||||
data: [{ id: "prod-1", name: "Fan-out Prod" }],
|
||||
}),
|
||||
}));
|
||||
|
||||
// ProjectSelector: a button that sets the project, so the test can populate
|
||||
// projectId without driving a data-fetching combobox.
|
||||
vi.mock("@/components/projects/project-selector", () => ({
|
||||
ProjectSelector: ({ onChange }: { onChange: (v: string | null) => void }) => (
|
||||
<button type="button" onClick={() => onChange("proj-1")}>
|
||||
Set Project
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// AcceptanceCriteriaEditor: a button that supplies one criterion, bypassing
|
||||
// the multi-input editor so the AC validation passes.
|
||||
vi.mock("../acceptance-criteria-editor", () => ({
|
||||
AcceptanceCriteriaEditor: ({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (c: string[]) => void;
|
||||
}) => (
|
||||
<button type="button" onClick={() => onChange(["criterion one"])}>
|
||||
Add Criterion
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// MarkdownEditor: a controlled textarea so the test can type a >=20-char desc.
|
||||
vi.mock("../markdown-editor", () => ({
|
||||
MarkdownEditor: ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
label: string;
|
||||
}) => (
|
||||
<label>
|
||||
{label}
|
||||
<textarea
|
||||
aria-label={label}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
}));
|
||||
|
||||
// DependencySelector / TaskSelector / AgentSelector: optional, render nothing.
|
||||
vi.mock("../dependency-selector", () => ({
|
||||
DependencySelector: () => null,
|
||||
}));
|
||||
vi.mock("../task-selector", () => ({ TaskSelector: () => null }));
|
||||
vi.mock("@/components/agents/agent-selector", () => ({
|
||||
AgentSelector: () => null,
|
||||
}));
|
||||
|
||||
// Collapsible: render the advanced section open so the Project/Product fields
|
||||
// are reachable without a pointer-driven toggle.
|
||||
vi.mock("@/components/ui/collapsible", () => ({
|
||||
Collapsible: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
CollapsibleTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
CollapsibleContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
}));
|
||||
|
||||
// Select: a native <select> stub so the Product picker (a radix Select) can be
|
||||
// driven with fireEvent.change. All the dialog's selects become native ones;
|
||||
// they carry their own default values, so only the Product select needs action.
|
||||
vi.mock("@/components/ui/select", () => ({
|
||||
Select: ({
|
||||
value,
|
||||
onValueChange,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
onValueChange?: (v: string) => void;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<select value={value} onChange={(e) => onValueChange?.(e.target.value)}>
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
SelectValue: () => null,
|
||||
SelectContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
SelectItem: ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
}) => <option value={value}>{children}</option>,
|
||||
}));
|
||||
|
||||
import { CreateTaskDialog } from "../create-task-dialog";
|
||||
|
||||
function fillBasics() {
|
||||
fireEvent.change(screen.getByPlaceholderText(/Enter task title/i), {
|
||||
target: { value: "A task title" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Description"), {
|
||||
target: {
|
||||
value: "A description long enough to pass the twenty char minimum.",
|
||||
},
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Criterion" }));
|
||||
}
|
||||
|
||||
// The Product select is the only native <select> offering the prod-1 option.
|
||||
// radix Dialog portals content to document.body, so query the document (not
|
||||
// the render container).
|
||||
function productSelect() {
|
||||
const selects = Array.from(document.querySelectorAll("select"));
|
||||
return selects.find((s) => s.querySelector('option[value="prod-1"]'))!;
|
||||
}
|
||||
|
||||
function submit() {
|
||||
fireEvent.submit(document.querySelector("form")!);
|
||||
}
|
||||
|
||||
describe("CreateTaskDialog — project/product mutual exclusivity (F085)", () => {
|
||||
beforeEach(() => {
|
||||
mutateAsync.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("rejects submitting both a Project and a Product", async () => {
|
||||
render(<CreateTaskDialog />);
|
||||
// Open the dialog (DialogTrigger renders a "New Task" button).
|
||||
fireEvent.click(screen.getByRole("button", { name: /New Task/i }));
|
||||
|
||||
fillBasics();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set Project" }));
|
||||
fireEvent.change(productSelect(), { target: { value: "prod-1" } });
|
||||
|
||||
submit();
|
||||
|
||||
// A task targets ONE repo OR fans out via a Product — never both. The
|
||||
// server silently lets product_id win and drops project_id, so the panel
|
||||
// must refuse the ambiguous submit. Before the fix validate() only checked
|
||||
// "at least one", so both sailed through to mutateAsync.
|
||||
await waitFor(() => {
|
||||
expect(mutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(
|
||||
screen.getByText(/either a project or a product/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits with project_id only when just a Project is picked", async () => {
|
||||
render(<CreateTaskDialog />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /New Task/i }));
|
||||
|
||||
fillBasics();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set Project" }));
|
||||
|
||||
submit();
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
|
||||
const payload = mutateAsync.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(payload.project_id).toBe("proj-1");
|
||||
expect(payload.product_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("submits with product_id only when just a Product is picked", async () => {
|
||||
render(<CreateTaskDialog />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /New Task/i }));
|
||||
|
||||
fillBasics();
|
||||
fireEvent.change(productSelect(), { target: { value: "prod-1" } });
|
||||
|
||||
submit();
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
|
||||
const payload = mutateAsync.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(payload.product_id).toBe("prod-1");
|
||||
expect(payload.project_id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,16 @@ export function CreateTaskDialog() {
|
||||
"Pick a Project (the repo) or a Product (cell→project map for a fan-out task)";
|
||||
}
|
||||
|
||||
// A task targets exactly one repo OR fans out via a Product — never both.
|
||||
// The server silently lets product_id win at routing and drops project_id,
|
||||
// so submitting both would record a misleading, never-used project_id. The
|
||||
// selectors clear each other on pick (below); this validator is the
|
||||
// backstop for any path that sets both.
|
||||
if (projectId && productId) {
|
||||
newErrors.project_id =
|
||||
"Pick either a Project or a Product, not both — a task targets one repo or fans out via a Product";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user