import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueTriage } from "./issue-triage";
const {
addIssueNoteMock,
getIssueNotesMock,
refreshMock,
toastMock,
updateIssueTriageMock,
} = vi.hoisted(() => ({
addIssueNoteMock: vi.fn(),
getIssueNotesMock: vi.fn(),
refreshMock: vi.fn(),
toastMock: vi.fn(),
updateIssueTriageMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: refreshMock }),
}));
vi.mock("@/actions/issues", () => ({
addIssueNote: addIssueNoteMock,
getIssueNotes: getIssueNotesMock,
updateIssueTriage: updateIssueTriageMock,
}));
vi.mock("@/components/shadcn/toast/use-toast", () => ({
useToast: () => ({ toast: toastMock }),
}));
describe("IssueTriage", () => {
beforeAll(() => {
// Radix Select needs pointer capture and scrollIntoView, which jsdom lacks.
for (const name of [
"hasPointerCapture",
"setPointerCapture",
"releasePointerCapture",
"scrollIntoView",
]) {
Object.defineProperty(HTMLElement.prototype, name, {
configurable: true,
value: vi.fn(() => false),
});
}
});
beforeEach(() => {
getIssueNotesMock.mockResolvedValue([
{
id: "note-1",
body: "Public by design.",
insertedAt: "2026-10-20T10:00:00Z",
author: "Ana",
},
]);
updateIssueTriageMock.mockResolvedValue({ success: true });
addIssueNoteMock.mockResolvedValue({ success: true });
});
it("lists the notes already on the issue, with their author", async () => {
render();
expect(await screen.findByText("Public by design.")).toBeInTheDocument();
expect(screen.getByText("Ana")).toBeInTheDocument();
expect(screen.getByText("Oct 20, 2026")).toBeInTheDocument();
});
it("saves a new status together with its note", async () => {
// Given
const user = userEvent.setup();
render();
expect(screen.getByRole("button", { name: "Save triage" })).toBeDisabled();
// When
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
await user.click(screen.getByRole("option", { name: "Risk Accepted" }));
await user.type(
screen.getByRole("textbox", { name: "Triage note" }),
" Static site. ",
);
await user.click(screen.getByRole("button", { name: "Save triage" }));
// Then
expect(updateIssueTriageMock).toHaveBeenCalledWith("issue-1", {
status: "risk_accepted",
note: "Static site.",
});
expect(addIssueNoteMock).not.toHaveBeenCalled();
expect(refreshMock).toHaveBeenCalled();
expect(screen.getByRole("textbox", { name: "Triage note" })).toHaveValue(
"",
);
});
it("records a note alone without touching the status", async () => {
const user = userEvent.setup();
render();
await user.type(
screen.getByRole("textbox", { name: "Triage note" }),
"Asked the owner.",
);
await user.click(screen.getByRole("button", { name: "Save triage" }));
expect(addIssueNoteMock).toHaveBeenCalledWith(
"issue-1",
"Asked the owner.",
);
expect(updateIssueTriageMock).not.toHaveBeenCalled();
});
it("follows a status someone else set, so a note alone does not undo it", async () => {
// Given
const user = userEvent.setup();
const { rerender } = render();
// When - a refresh brings another user's decision
rerender();
await user.type(
screen.getByRole("textbox", { name: "Triage note" }),
"Asked the owner.",
);
await user.click(screen.getByRole("button", { name: "Save triage" }));
// Then
expect(
screen.getByRole("combobox", { name: "Triage status" }),
).toHaveTextContent("Remediating");
expect(addIssueNoteMock).toHaveBeenCalledWith(
"issue-1",
"Asked the owner.",
);
expect(updateIssueTriageMock).not.toHaveBeenCalled();
});
it("locks the note while it is being saved", async () => {
// Given
let finish: (value: { success: true }) => void = () => {};
addIssueNoteMock.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
const user = userEvent.setup();
render();
const textbox = screen.getByRole("textbox", { name: "Triage note" });
// When
await user.type(textbox, "Asked the owner.");
await user.click(screen.getByRole("button", { name: "Save triage" }));
// Then
expect(textbox).toBeDisabled();
finish({ success: true });
await waitFor(() => expect(textbox).toBeEnabled());
});
it("keeps the note and says why when the save fails", async () => {
// Given
updateIssueTriageMock.mockResolvedValue({
error: "You do not have permission to perform this action.",
});
const user = userEvent.setup();
render();
// When
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
await user.click(screen.getByRole("option", { name: "False Positive" }));
await user.type(screen.getByRole("textbox", { name: "Triage note" }), "x");
await user.click(screen.getByRole("button", { name: "Save triage" }));
// Then
expect(toastMock).toHaveBeenCalledWith(
expect.objectContaining({
variant: "destructive",
description: "You do not have permission to perform this action.",
}),
);
expect(refreshMock).not.toHaveBeenCalled();
expect(screen.getByRole("textbox", { name: "Triage note" })).toHaveValue(
"x",
);
});
it("says so when the notes cannot be loaded, and loads them on retry", async () => {
const user = userEvent.setup();
getIssueNotesMock.mockResolvedValueOnce(undefined);
render();
expect(await screen.findByRole("alert")).toHaveTextContent(
"Could not load the notes for this issue.",
);
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("Public by design.")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});