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()), ...api, })); vi.mock("react-toastify", () => ({ toast })); vi.mock("react-router", async (importOriginal) => ({ ...(await importOriginal()), useNavigate: () => navigate, })); vi.mock("@/providers/auth-context", async (importOriginal) => ({ ...(await importOriginal()), 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(, { 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()); }); });