shoc-frontend-new/src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx

298 lines
13 KiB
TypeScript

import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HTTPError } from "ky";
import { beforeEach, describe, expect, it, vi } from "vitest";
import CompletionDocsPage from "@/app/(protected)/completion-docs/index";
import { renderWithProviders } from "@/test/test-utils";
import { BACKFLOW, SCRUBBER, serveTemplates } from "./completion-docs-fixtures";
const api = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiPut: vi.fn(),
apiDeleteNoContent: vi.fn(),
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
const navigate = vi.hoisted(() => vi.fn());
vi.mock("@/api/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/api/api")>()),
...api,
}));
vi.mock("react-toastify", () => ({ toast }));
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
useNavigate: () => navigate,
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/providers/auth-context")>()),
useAuthContext: () => ({ user: { id: "u1", userRoles: "Admin" } }),
}));
// delay: null skips per-keystroke timer yields that only slow typing-heavy tests down.
let user = userEvent.setup({ delay: null });
function renderPage() {
return renderWithProviders(<CompletionDocsPage />, {
route: "/completion-docs",
withAuth: false,
});
}
async function openNewTemplate() {
renderPage();
await screen.findByRole("row", { name: /Backflow Test Report/ });
await user.click(await screen.findByRole("button", { name: /Add Template/ }));
return screen.findByRole("dialog");
}
async function openTemplate(name: string) {
renderPage();
await user.click(await screen.findByRole("row", { name: new RegExp(name) }));
return screen.findByRole("dialog");
}
function previewHeadings(dialog: HTMLElement) {
const preview = within(dialog).getByTestId("completion-template-preview");
return within(preview)
.getAllByRole("heading", { level: 3 })
.map((heading) => heading.textContent);
}
/** A 422 as the API layer delivers it: ky's parsed body on `data`, the mapped text on `message`. */
function unprocessable(body: { code: string; message: string }, message = body.message) {
const error = new HTTPError(
new Response(null, { status: 422 }),
new Request("http://api/workorders/completion-templates"),
{} as never,
);
error.data = body;
error.message = message;
return error;
}
async function addProcedure(dialog: HTMLElement, index: number, name: string) {
await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" }));
await user.type(within(dialog).getByLabelText(`Procedure ${index} name`), name);
}
beforeEach(() => {
user = userEvent.setup({ delay: null });
vi.clearAllMocks();
serveTemplates(api, [BACKFLOW, SCRUBBER], { count: 0, workOrderIds: [] });
});
describe("Add Template", { timeout: 15_000 }, () => {
it("blocks saving without a Document Title and says why", async () => {
const dialog = await openNewTemplate();
await user.click(within(dialog).getByRole("button", { name: /Add Template/ }));
expect(await within(dialog).findByText("Document Title is required")).toBeInTheDocument();
expect(within(dialog).getByRole("textbox", { name: /Document Title/ })).toHaveFocus();
expect(api.apiPost).not.toHaveBeenCalled();
});
it("shows the fixed fields read-only and lets the author add then remove one safety note", async () => {
const dialog = await openNewTemplate();
const safety = within(dialog).getByRole("list", { name: "Safety notes" });
expect(within(safety).getAllByRole("listitem")).toHaveLength(3);
expect(within(dialog).getByText("WO#")).toBeInTheDocument();
expect(within(dialog).getAllByRole("textbox")).toHaveLength(1);
await user.click(within(dialog).getByRole("button", { name: "+ Add safety note" }));
expect(within(dialog).queryByRole("button", { name: "+ Add safety note" })).toBeNull();
await user.type(within(dialog).getByLabelText("Extra safety note"), "Mind the pit");
expect(within(dialog).getByTestId("completion-template-preview")).toHaveTextContent(
"Mind the pit",
);
await user.click(within(dialog).getByRole("button", { name: "Remove safety note" }));
expect(within(dialog).queryByLabelText("Extra safety note")).toBeNull();
expect(within(dialog).getByTestId("completion-template-preview")).not.toHaveTextContent(
"Mind the pit",
);
});
it("adds and removes procedures and previews them in editor order", async () => {
const dialog = await openNewTemplate();
expect(within(dialog).getByText("No procedure added yet.")).toBeInTheDocument();
await addProcedure(dialog, 1, "Isolate");
await addProcedure(dialog, 2, "Inspect");
await addProcedure(dialog, 3, "Restore");
expect(previewHeadings(dialog)).toEqual(["Isolate", "Inspect", "Restore"]);
await user.click(within(dialog).getByRole("button", { name: "Remove procedure 2" }));
expect(previewHeadings(dialog)).toEqual(["Isolate", "Restore"]);
expect(within(dialog).getByLabelText("Procedure 2 name")).toHaveValue("Restore");
await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" }));
expect(previewHeadings(dialog)).toEqual(["Isolate", "Restore", "Procedure 3"]);
});
it("saves the title, note and procedures in order, then confirms with a toast", async () => {
const dialog = await openNewTemplate();
await user.type(
within(dialog).getByRole("textbox", { name: /Document Title/ }),
" PRV Check ",
);
await user.click(within(dialog).getByRole("button", { name: "+ Add safety note" }));
await user.type(within(dialog).getByLabelText("Extra safety note"), "Gloves");
await addProcedure(dialog, 1, "Open");
await user.type(within(dialog).getByLabelText("Procedure 1 description"), "a{enter}b");
await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" }));
await addProcedure(dialog, 3, "Close");
await user.click(within(dialog).getByRole("button", { name: /Add Template/ }));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template added"));
expect(api.apiPost).toHaveBeenCalledWith("workorders/completion-templates", {
name: "PRV Check",
extraSafetyNote: "Gloves",
procedures: [
{ name: "Open", description: "a\nb" },
{ name: "Close", description: "" },
],
});
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
it("shows the server's blank-title rejection on the title field", async () => {
api.apiPost.mockRejectedValue(
unprocessable({ code: "TitleRequired", message: "Document Title is required." }),
);
const dialog = await openNewTemplate();
await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), "X");
await user.click(within(dialog).getByRole("button", { name: /Add Template/ }));
expect(await within(dialog).findByText("Document Title is required")).toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
});
it("reports any other rejected value in a toast and keeps the form open", async () => {
const message = "Procedure name must be 200 characters or fewer.";
api.apiPost.mockRejectedValue(unprocessable({ code: "InvalidValue", message }, message));
const dialog = await openNewTemplate();
await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), "X");
await user.click(within(dialog).getByRole("button", { name: /Add Template/ }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message));
expect(within(dialog).queryByText("Document Title is required")).toBeNull();
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});
describe("Edit template", { timeout: 15_000 }, () => {
it("Cancel discards unsaved changes and returns to the original read-only values", async () => {
const dialog = await openTemplate("Backflow Test Report");
await user.click(within(dialog).getByRole("button", { name: /Edit/ }));
expect(within(dialog).getByText("Live preview")).toBeInTheDocument();
const title = within(dialog).getByRole("textbox", { name: /Document Title/ });
await user.clear(title);
await user.type(title, "Renamed");
await user.click(within(dialog).getByRole("button", { name: "Remove procedure 1" }));
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
expect(within(dialog).queryByRole("textbox")).not.toBeInTheDocument();
expect(within(dialog).getAllByText("Backflow Test Report").length).toBeGreaterThan(0);
expect(within(dialog).queryByText("Renamed")).not.toBeInTheDocument();
expect(previewHeadings(dialog)).toEqual(["Backflow Procedure", "Shut-off"]);
expect(api.apiPut).not.toHaveBeenCalled();
await user.click(within(dialog).getByRole("button", { name: /Edit/ }));
expect(within(dialog).getByRole("textbox", { name: /Document Title/ })).toHaveValue(
"Backflow Test Report",
);
});
it("saves an edit, keeps the legacy fields and confirms with a toast", async () => {
const dialog = await openTemplate("Backflow Test Report");
await user.click(within(dialog).getByRole("button", { name: /Edit/ }));
await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), " v2");
await user.click(within(dialog).getByRole("button", { name: /Save changes/ }));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated"));
expect(api.apiPut).toHaveBeenCalledWith("workorders/completion-templates/12", {
serviceKey: "backflow",
workOrderType: 2,
templateUrl: "https://files.example/backflow.pdf",
isActive: true,
name: "Backflow Test Report v2",
extraSafetyNote: "Wear gloves",
procedures: [
{ name: "Backflow Procedure", description: "line 1\nline 2" },
{ name: "Shut-off", description: "Close valve" },
],
});
expect(
await within(dialog).findByText("Created by Jane Doe · Updated Sep 25, 2026"),
).toBeInTheDocument();
});
it("stays closed when the user closes it while a save is still in flight", async () => {
let finishSave: (value: unknown) => void = () => undefined;
api.apiPut.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishSave = resolve;
}),
);
const dialog = await openTemplate("Backflow Test Report");
await user.click(within(dialog).getByRole("button", { name: /Edit/ }));
await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), " v2");
await user.click(within(dialog).getByRole("button", { name: /Save changes/ }));
await waitFor(() => expect(api.apiPut).toHaveBeenCalled());
await user.click(within(dialog).getByRole("button", { name: "Close" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
finishSave({ ...BACKFLOW, name: "Backflow Test Report v2" });
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated"));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
describe("Delete template", { timeout: 15_000 }, () => {
it("states how many open work orders depend on it and links to exactly those", async () => {
serveTemplates(api, [BACKFLOW, SCRUBBER], { count: 2, workOrderIds: [101, 205] });
const dialog = await openTemplate("Backflow Test Report");
await user.click(within(dialog).getByRole("button", { name: /Delete/ }));
const confirm = await screen.findByRole("dialog", { name: "Delete this template?" });
expect(
await within(confirm).findByText(
/It's still linked to 2 open work orders through its Service/,
),
).toBeInTheDocument();
expect(api.apiDeleteNoContent).not.toHaveBeenCalled();
await user.click(within(confirm).getByRole("button", { name: "View linked work orders" }));
expect(navigate).toHaveBeenCalledWith("/workorders?ids=101,205");
});
it("still asks first when nothing depends on it, then deletes with a toast", async () => {
const dialog = await openTemplate("Scrubber Pit Augering");
await user.click(within(dialog).getByRole("button", { name: /Delete/ }));
const confirm = await screen.findByRole("dialog", { name: "Delete this template?" });
expect(
await within(confirm).findByText(
'This will permanently remove "Scrubber Pit Augering". This cannot be undone.',
),
).toBeInTheDocument();
expect(within(confirm).queryByRole("button", { name: "View linked work orders" })).toBeNull();
await user.click(within(confirm).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template deleted"));
expect(api.apiDeleteNoContent).toHaveBeenCalledWith("workorders/completion-templates/13");
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
});