shoc-frontend-new/src/test/app/(protected)/locations/site-delete.test.tsx
Alexandre Brandizzi 2281c7c82d fix(sites): open the site's board when open work orders exceed the id cap
The delete dialog states the server's full open work-order count, but the
server returns at most 200 ids. "View open work orders" navigated with
that capped list, so a site with 240 open work orders showed 200, and an
empty list opened the unfiltered board.

The exact-id link is now used only when the ids cover the whole count.
Otherwise the link opens Work Orders filtered to the site and every open
status across all weeks, the ticket's "board filtered to that site". The
board drilldown now reads a `sites` param for this; `ids` still wins.
2026-09-25 17:10:51 -03:00

250 lines
9.3 KiB
TypeScript

import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { HTTPError } from "ky";
import { Route, Routes, useLocation } from "react-router";
import { toast } from "react-toastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LocationsListPage from "@/app/(protected)/locations/index";
import { normalizeApiRequestError } from "@/api/api-error";
import type { Location, LocationListItem } from "@/domain/locations/types/location";
import { AuthContext, type AuthContextValue } from "@/providers/auth-context";
import { renderWithProviders } from "@/test/test-utils";
const getList = vi.fn();
const getById = vi.fn();
const getOpenWorkOrders = vi.fn();
const deleteSite = vi.fn();
vi.mock("@/domain/locations/api/locations-api", () => ({
locationsApi: {
getList: (...args: unknown[]) => getList(...args),
getById: (...args: unknown[]) => getById(...args),
getOpenWorkOrders: (...args: unknown[]) => getOpenWorkOrders(...args),
delete: (...args: unknown[]) => deleteSite(...args),
update: vi.fn(),
create: vi.fn(),
},
}));
vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({
useAccountsList: () => ({
data: { items: [], totalCount: 0, page: 1, pageSize: 50 },
isLoading: false,
}),
}));
vi.mock("@/hooks/use-debounce", () => ({
useDebounce: (value: string) => value,
}));
const site: Location = {
id: 1,
name: "DAL1",
clientName: "Acme Logistics",
accountId: 7,
address: "3811 Distribution Dr",
city: "Dallas",
state: "TX",
contacts: [{ id: 31, name: "Jane", phone: "(421) 433-0032" }],
};
const row: LocationListItem = {
id: 1,
name: "DAL1",
clientName: "Acme Logistics",
sitePhone: "",
address: "3811 Distribution Dr",
city: "Dallas",
state: "TX",
zipCode: "",
phone: "(421) 433-0032",
contact: "Jane",
contactEmail: "",
status: "Active",
accountId: 7,
contacts: site.contacts,
};
function WorkOrdersProbe() {
const location = useLocation();
return <div data-testid="work-orders-url">{`${location.pathname}${location.search}`}</div>;
}
function renderAs(userRoles: string) {
const auth = { user: { userRoles } } as unknown as AuthContextValue;
return renderWithProviders(
<AuthContext.Provider value={auth}>
<Routes>
<Route path="/locations" element={<LocationsListPage />} />
<Route path="/workorders" element={<WorkOrdersProbe />} />
</Routes>
</AuthContext.Provider>,
{ withAuth: false, route: "/locations" },
);
}
async function openPanel(): Promise<HTMLElement> {
fireEvent.click(await screen.findByRole("button", { name: "DAL1" }));
const panel = screen.getByRole("region", { name: "Site details" });
await within(panel).findByRole("button", { name: "Edit" });
return panel;
}
function forbidden(): HTTPError {
const body = { status: "Error", message: "You are not allowed to delete sites." };
const error = new HTTPError(
new Response(JSON.stringify(body), { status: 403 }),
new Request("https://api.example.test/api/locations/1"),
{} as never,
);
error.data = body;
return normalizeApiRequestError(error) as HTTPError;
}
describe("Delete a site from the detail panel", () => {
beforeEach(() => {
for (const mock of [getList, getById, getOpenWorkOrders, deleteSite]) {
mock.mockReset();
}
getList.mockResolvedValue({ items: [row], totalCount: 1, page: 1, pageSize: 12 });
getById.mockResolvedValue(site);
});
it.each(["Admin", "Scheduler"])("offers Delete in the view footer to %s", async (role) => {
renderAs(role);
const panel = await openPanel();
expect(within(panel).getByRole("button", { name: "Delete" })).toBeInTheDocument();
});
it("hides Delete from a Dispatcher and removes the old row delete control", async () => {
renderAs("Dispatcher");
const panel = await openPanel();
expect(within(panel).queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /delete site/i })).not.toBeInTheDocument();
});
it("warns with the open work-order count and links to exactly those work orders", async () => {
getOpenWorkOrders.mockResolvedValue({ count: 3, workOrderIds: [101, 205, 318] });
renderAs("Scheduler");
const panel = await openPanel();
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
expect(
await within(dialog).findByText(
"This will permanently remove DAL1 from Sites. It still has 3 open work orders — deleting the site won't cancel them automatically. This cannot be undone.",
),
).toBeInTheDocument();
expect(getOpenWorkOrders).toHaveBeenCalledWith("1");
fireEvent.click(within(dialog).getByRole("button", { name: "View open work orders" }));
expect(await screen.findByTestId("work-orders-url")).toHaveTextContent(
"/workorders?ids=101,205,318",
);
expect(deleteSite).not.toHaveBeenCalled();
});
it.each([
["more open work orders than the capped id list", { count: 240, workOrderIds: [101, 205] }],
["an open count with no ids", { count: 4, workOrderIds: [] }],
])("links to the site's open work-order board when there are %s", async (_label, result) => {
getOpenWorkOrders.mockResolvedValue(result);
renderAs("Admin");
const panel = await openPanel();
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
await within(dialog).findByText(new RegExp(`It still has ${result.count} open work orders`));
fireEvent.click(within(dialog).getByRole("button", { name: "View open work orders" }));
const url = new URL(
(await screen.findByTestId("work-orders-url")).textContent ?? "",
"https://app.test",
);
expect(url.pathname).toBe("/workorders");
expect(url.searchParams.get("ids")).toBeNull();
expect(url.searchParams.get("sites")).toBe("1");
expect(url.searchParams.get("statuses")?.split(",")).toEqual([
"Incomplete",
"Pending",
"Scheduled",
"En Route",
"On Site",
"In Progress",
"Rescheduled",
"Pending Quote",
]);
});
it("confirms without a warning when nothing is open, deletes and closes the panel", async () => {
getOpenWorkOrders.mockResolvedValue({ count: 0, workOrderIds: [] });
deleteSite.mockResolvedValue(undefined);
renderAs("Admin");
const panel = await openPanel();
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
expect(
await within(dialog).findByText(
"This will permanently remove DAL1 from Sites. This cannot be undone.",
),
).toBeInTheDocument();
expect(
within(dialog).queryByRole("button", { name: "View open work orders" }),
).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => expect(deleteSite).toHaveBeenCalledWith("1"));
await waitFor(() =>
expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(),
);
});
it("blocks Delete when the open work-order check fails, until a retry succeeds", async () => {
getOpenWorkOrders.mockRejectedValueOnce(new Error("Server error"));
getOpenWorkOrders.mockResolvedValueOnce({ count: 2, workOrderIds: [101, 205] });
renderAs("Admin");
const panel = await openPanel();
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
"Couldn't check this site for open work orders. Try again before deleting it.",
);
expect(within(dialog).getByRole("button", { name: "Delete" })).toBeDisabled();
expect(within(dialog).queryByText(/This cannot be undone/)).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Try again" }));
expect(await within(dialog).findByText(/It still has 2 open work orders/)).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: "Delete" })).toBeEnabled();
expect(within(dialog).getByRole("button", { name: "View open work orders" })).toBeEnabled();
expect(deleteSite).not.toHaveBeenCalled();
});
it("keeps the site and shows the server's refusal when the API answers 403", async () => {
getOpenWorkOrders.mockResolvedValue({ count: 0, workOrderIds: [] });
deleteSite.mockRejectedValue(forbidden());
const toastError = vi.spyOn(toast, "error");
renderAs("Admin");
const panel = await openPanel();
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
await within(dialog).findByText(/This cannot be undone/);
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() =>
expect(toastError).toHaveBeenCalledWith("You do not have permission to perform this action."),
);
expect(screen.getByRole("dialog", { name: "Delete this site?" })).toBeInTheDocument();
expect(within(panel).getByRole("heading", { name: "DAL1", hidden: true })).toBeInTheDocument();
});
});