mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 00:53:14 +00:00
298 lines
13 KiB
TypeScript
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());
|
|
});
|
|
});
|