diff --git a/panel/src/components/tasks/task-detail/__tests__/task-header-godmode.test.tsx b/panel/src/components/tasks/task-detail/__tests__/task-header-godmode.test.tsx new file mode 100644 index 00000000..9672fefb --- /dev/null +++ b/panel/src/components/tasks/task-detail/__tests__/task-header-godmode.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi } from "vitest"; +import { render } from "@testing-library/react"; +import React from "react"; +import { TaskStatus, Team, TaskType, type Task } from "@/types"; + +// God-mode status override: forcing a task into a hatch/terminal state via the +// header Select must send `force: true`, else the backend refuses the PATCH +// with 400 (the lifecycle-bypass acknowledgement added by the gap sweep). + +const { mutateAsync } = vi.hoisted(() => ({ + mutateAsync: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), back: vi.fn() }), +})); + +vi.mock("@/hooks/use-tasks", () => ({ + useUpdateTask: () => ({ mutateAsync, isPending: false }), + useDeleteTask: () => ({ mutateAsync: vi.fn(), isPending: false }), + // Empty valid-transitions => every other status is a god-mode override. + useTaskValidTransitions: () => ({ data: [], isLoading: false }), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +// Make the Select testable without Radix's portal/pointer machinery: each +// SelectItem renders a button carrying its value; clicking it invokes the +// nearest Select's onValueChange (scoped via context so the status Select and +// the team Select don't cross-fire). +vi.mock("@/components/ui/select", () => { + const Ctx = React.createContext<(v: string) => void>(() => {}); + return { + Select: ({ + onValueChange, + children, + }: { + onValueChange?: (v: string) => void; + children: React.ReactNode; + }) => ( + {})}>{children} + ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SelectValue: () => null, + SelectContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + SelectItem: ({ + value, + children, + }: { + value: string; + children: React.ReactNode; + }) => { + const onValueChange = React.useContext(Ctx); + return ( + + ); + }, + }; +}); + +import { TaskHeader } from "../task-header"; + +function buildTask(): Task { + return { + id: "t1", + title: "Wedged task", + description: "d", + status: TaskStatus.IN_PROGRESS, + team: Team.BACKEND, + task_type: TaskType.CODE, + acceptance_criteria: [], + } as unknown as Task; +} + +describe("TaskHeader god-mode status override", () => { + it("sends force: true when forcing a hatch/terminal status", async () => { + const { container } = render(); + const btn = container.querySelector( + `[data-value="${TaskStatus.COMPLETED}"]`, + ); + expect(btn).not.toBeNull(); + btn?.click(); + await vi.waitFor(() => + expect(mutateAsync).toHaveBeenCalledWith({ + taskId: "t1", + updates: { status: TaskStatus.COMPLETED, force: true }, + }), + ); + }); +}); diff --git a/panel/src/components/tasks/task-detail/task-header.tsx b/panel/src/components/tasks/task-detail/task-header.tsx index 3ba4cbf9..da1109ec 100644 --- a/panel/src/components/tasks/task-detail/task-header.tsx +++ b/panel/src/components/tasks/task-detail/task-header.tsx @@ -236,10 +236,13 @@ export function TaskHeader({ task, onAction }: TaskHeaderProps) { // reopening a `cancelled` task). Audited via PATCH /tasks/{id} {status} -> // admin_set_status. No PR merge / lifecycle side effects fire — this is a // pure, operator-driven state correction the CEO is entitled to make. + // `force: true` is the explicit acknowledgement the backend requires to + // override into a hatch state (awaiting_*, completed, cancelled) or to + // resurrect a terminal task; without it the PATCH is refused with 400. try { await updateTask.mutateAsync({ taskId: task.id, - updates: { status: newStatus }, + updates: { status: newStatus, force: true }, }); toast.success(`Status forced to ${statusLabels[newStatus]}`); } catch {