fix(panel): send force:true on god-mode status override (#288)

The header status Select's god-mode branch (force ANY status) PATCHed
/tasks/{id} with {status} but no force. The gap-sweep backend now requires
'force: true' to override into a hatch state (awaiting_*, completed, cancelled)
or resurrect a terminal task — without it admin_set_status refuses with 400, so
the CEO could no longer force a wedged task from the task page. The kanban
bypass path was updated in the sweep; this header path was missed. Add
force:true (the branch already only runs for out-of-band targets) + a focused
test locking the payload.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-01 03:06:23 +02:00
committed by GitHub
co-authored by Renn F
parent df87fcf059
commit 1e341c766e
2 changed files with 102 additions and 1 deletions
@@ -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;
}) => (
<Ctx.Provider value={onValueChange ?? (() => {})}>{children}</Ctx.Provider>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => {
const onValueChange = React.useContext(Ctx);
return (
<button data-value={value} onClick={() => onValueChange(value)}>
{children}
</button>
);
},
};
});
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(<TaskHeader task={buildTask()} />);
const btn = container.querySelector<HTMLButtonElement>(
`[data-value="${TaskStatus.COMPLETED}"]`,
);
expect(btn).not.toBeNull();
btn?.click();
await vi.waitFor(() =>
expect(mutateAsync).toHaveBeenCalledWith({
taskId: "t1",
updates: { status: TaskStatus.COMPLETED, force: true },
}),
);
});
});
@@ -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 {