= {
+ pending: "loading",
+ error: "error",
+ success: "ready",
+};
+
+interface UseVendorPortalSessionResult {
+ vendor: VendorPortalSession | null;
+ status: VendorPortalStatus;
+ error: Error | null;
+}
+
+export function useVendorPortalSession(token: string): UseVendorPortalSessionResult {
+ const { data, status, error } = useQuery({
+ queryKey: queryKeys.vendorPortal.session(token),
+ queryFn: () => vendorPortalApi.session(token),
+ });
+
+ return {
+ vendor: data ?? null,
+ status: VENDOR_PORTAL_STATUS_BY_QUERY_STATUS[status],
+ error: error ?? null,
+ };
+}
diff --git a/src/test/app/(protected)/settings/task-templates.test.tsx b/src/test/app/(protected)/settings/task-templates.test.tsx
new file mode 100644
index 00000000..876a5cd1
--- /dev/null
+++ b/src/test/app/(protected)/settings/task-templates.test.tsx
@@ -0,0 +1,178 @@
+import { fireEvent, screen, waitFor, within } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import TaskTemplatesPage from "@/app/(protected)/settings/task-templates";
+import { taskTemplatesApi } from "@/domain/settings/task-templates/api/task-templates-api";
+import type { TaskTemplate } from "@/domain/settings/task-templates/types/task-template";
+import { renderWithProviders } from "@/test/test-utils";
+
+const existingTemplate: TaskTemplate = {
+ id: "5",
+ name: "HVAC Inspection",
+ description: "Standard checklist",
+ isActive: true,
+ items: [
+ { itemText: "Check filters", sortOrder: 0 },
+ { itemText: "Check refrigerant", sortOrder: 1 },
+ ],
+};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+function renderPage(templates: TaskTemplate[] = [existingTemplate]) {
+ vi.spyOn(taskTemplatesApi, "getAll").mockResolvedValue(templates);
+ return renderWithProviders(, { withAuth: false });
+}
+
+describe("TaskTemplatesPage", () => {
+ it("shows a placeholder until a template is selected or created", async () => {
+ renderPage();
+
+ expect(await screen.findByText("HVAC Inspection")).toBeInTheDocument();
+ expect(screen.getByText("Select a template or create a new one")).toBeInTheDocument();
+ });
+
+ it("selecting a template from the list populates the detail form", async () => {
+ renderPage();
+
+ fireEvent.click(await screen.findByText("HVAC Inspection"));
+
+ expect(await screen.findByLabelText(/^Template Name/)).toHaveValue("HVAC Inspection");
+ expect(screen.getByLabelText("Description")).toHaveValue("Standard checklist");
+ expect(screen.getByText("Checklist Items (2)")).toBeInTheDocument();
+ expect(screen.getByText("Check filters")).toBeInTheDocument();
+ expect(screen.getByText("Check refrigerant")).toBeInTheDocument();
+ });
+
+ it("starting a new template shows an empty form with Create Template action", async () => {
+ renderPage();
+ await screen.findByText("HVAC Inspection");
+
+ fireEvent.click(screen.getByRole("button", { name: "+ New Template" }));
+
+ expect(await screen.findByLabelText(/^Template Name/)).toHaveValue("");
+ expect(screen.getByRole("button", { name: "Create Template" })).toBeDisabled();
+ expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
+ });
+
+ it("enables Create Template only once a name is entered", async () => {
+ renderPage();
+ await screen.findByText("HVAC Inspection");
+ fireEvent.click(screen.getByRole("button", { name: "+ New Template" }));
+
+ const nameInput = await screen.findByLabelText(/^Template Name/);
+ fireEvent.change(nameInput, { target: { value: "New Checklist" } });
+
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: "Create Template" })).toBeEnabled(),
+ );
+ });
+
+ it("adds a checklist item and appends it to the end of the list", async () => {
+ renderPage();
+ fireEvent.click(await screen.findByText("HVAC Inspection"));
+ await screen.findByText("Checklist Items (2)");
+
+ fireEvent.change(screen.getByPlaceholderText("Add checklist item..."), {
+ target: { value: "New Item" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Add" }));
+
+ expect(await screen.findByText("Checklist Items (3)")).toBeInTheDocument();
+ const rows = screen.getAllByText(/^(Check filters|Check refrigerant|New Item)$/);
+ expect(rows.map((row) => row.textContent)).toEqual([
+ "Check filters",
+ "Check refrigerant",
+ "New Item",
+ ]);
+ });
+
+ it("moves an item up and down without changing the item count", async () => {
+ renderPage();
+ fireEvent.click(await screen.findByText("HVAC Inspection"));
+ await screen.findByText("Checklist Items (2)");
+
+ fireEvent.click(screen.getByRole("button", { name: "Move item 2 up" }));
+
+ let rows = screen.getAllByText(/^(Check filters|Check refrigerant)$/);
+ expect(rows.map((row) => row.textContent)).toEqual(["Check refrigerant", "Check filters"]);
+
+ fireEvent.click(screen.getByRole("button", { name: "Move item 1 down" }));
+
+ rows = screen.getAllByText(/^(Check filters|Check refrigerant)$/);
+ expect(rows.map((row) => row.textContent)).toEqual(["Check filters", "Check refrigerant"]);
+ });
+
+ it("removes an item from the checklist", async () => {
+ renderPage();
+ fireEvent.click(await screen.findByText("HVAC Inspection"));
+ await screen.findByText("Checklist Items (2)");
+
+ fireEvent.click(screen.getByRole("button", { name: "Remove item 1" }));
+
+ expect(await screen.findByText("Checklist Items (1)")).toBeInTheDocument();
+ expect(screen.queryByText("Check filters")).not.toBeInTheDocument();
+ expect(screen.getByText("Check refrigerant")).toBeInTheDocument();
+ });
+
+ it("saves an edited template through the update mutation", async () => {
+ vi.spyOn(taskTemplatesApi, "update").mockResolvedValue(existingTemplate);
+ renderPage();
+
+ fireEvent.click(await screen.findByText("HVAC Inspection"));
+ const descriptionInput = await screen.findByLabelText("Description");
+ fireEvent.change(descriptionInput, { target: { value: "Updated checklist" } });
+
+ fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
+
+ await waitFor(() =>
+ expect(taskTemplatesApi.update).toHaveBeenCalledWith(
+ "5",
+ expect.objectContaining({ description: "Updated checklist" }),
+ ),
+ );
+ });
+
+ it("deletes a template after confirming in the dialog", async () => {
+ vi.spyOn(taskTemplatesApi, "delete").mockResolvedValue(undefined);
+ renderPage();
+
+ fireEvent.click(await screen.findByText("HVAC Inspection"));
+ fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
+
+ const dialog = await screen.findByRole("dialog");
+ expect(within(dialog).getByText(/Are you sure you want to delete/)).toHaveTextContent(
+ "HVAC Inspection",
+ );
+
+ fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
+
+ await waitFor(() => expect(taskTemplatesApi.delete).toHaveBeenCalledWith("5"));
+ await waitFor(() =>
+ expect(screen.getByText("Select a template or create a new one")).toBeInTheDocument(),
+ );
+ });
+
+ it("cancelling the delete dialog keeps the template intact", async () => {
+ const deleteSpy = vi.spyOn(taskTemplatesApi, "delete");
+ renderPage();
+
+ fireEvent.click(await screen.findByText("HVAC Inspection"));
+ fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
+ const dialog = await screen.findByRole("dialog");
+
+ fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
+
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+ expect(deleteSpy).not.toHaveBeenCalled();
+ expect(screen.getByLabelText(/^Template Name/)).toHaveValue("HVAC Inspection");
+ });
+
+ it("surfaces a load error banner when the template list fails to fetch", async () => {
+ vi.spyOn(taskTemplatesApi, "getAll").mockRejectedValue(new Error("Network down"));
+ renderWithProviders(, { withAuth: false });
+
+ expect(await screen.findByRole("alert")).toHaveTextContent("Network down");
+ });
+});
diff --git a/src/test/app/v/vendor-portal-provider.test.tsx b/src/test/app/v/vendor-portal-provider.test.tsx
new file mode 100644
index 00000000..264b0219
--- /dev/null
+++ b/src/test/app/v/vendor-portal-provider.test.tsx
@@ -0,0 +1,89 @@
+import { screen } from "@testing-library/react";
+import { Route, Routes } from "react-router";
+import { describe, expect, it, vi } from "vitest";
+import { useVendorPortal } from "@/app/v/_components/vendor-portal-context";
+import { VendorPortalProvider } from "@/app/v/_components/vendor-portal-provider";
+import type { VendorPortalSession } from "@/domain/vendor-portal/types/vendor-portal";
+import { renderWithProviders } from "@/test/test-utils";
+
+const useVendorPortalSession = vi.fn();
+
+vi.mock("@/domain/vendor-portal/use-cases/use-vendor-portal-session", () => ({
+ useVendorPortalSession: (...args: unknown[]) => useVendorPortalSession(...args),
+}));
+
+function VendorPortalConsumer() {
+ const { token, vendor, status, error } = useVendorPortal();
+ return (
+
+ {token}
+ {status}
+ {vendor?.companyName ?? "none"}
+ {error?.message ?? "none"}
+
+ );
+}
+
+function renderPortal(token: string) {
+ return renderWithProviders(
+
+
+
+
+ }
+ />
+ ,
+ {
+ route: `/v/${token}`,
+ routerProps: { initialEntries: [`/v/${token}`] },
+ withAuth: false,
+ },
+ );
+}
+
+describe("VendorPortalProvider", () => {
+ it("exposes loading status while the session query is pending", () => {
+ useVendorPortalSession.mockReturnValue({ vendor: null, status: "loading", error: null });
+
+ renderPortal("token-a");
+
+ expect(screen.getByTestId("token")).toHaveTextContent("token-a");
+ expect(screen.getByTestId("status")).toHaveTextContent("loading");
+ expect(screen.getByTestId("vendor-name")).toHaveTextContent("none");
+ });
+
+ it("exposes ready status with the resolved vendor once the session loads", () => {
+ const vendor: VendorPortalSession = { companyName: "Acme Vendor", vendorId: 42 };
+ useVendorPortalSession.mockReturnValue({ vendor, status: "ready", error: null });
+
+ renderPortal("token-b");
+
+ expect(screen.getByTestId("status")).toHaveTextContent("ready");
+ expect(screen.getByTestId("vendor-name")).toHaveTextContent("Acme Vendor");
+ expect(screen.getByTestId("error")).toHaveTextContent("none");
+ });
+
+ it("exposes error status and message when the session lookup fails", () => {
+ useVendorPortalSession.mockReturnValue({
+ vendor: null,
+ status: "error",
+ error: new Error("Invalid or expired link"),
+ });
+
+ renderPortal("token-c");
+
+ expect(screen.getByTestId("status")).toHaveTextContent("error");
+ expect(screen.getByTestId("error")).toHaveTextContent("Invalid or expired link");
+ });
+
+ it("passes the token straight from the route param through to the hook", () => {
+ useVendorPortalSession.mockReturnValue({ vendor: null, status: "loading", error: null });
+
+ renderPortal("route-token-123");
+
+ expect(useVendorPortalSession).toHaveBeenCalledWith("route-token-123");
+ });
+});
diff --git a/src/test/domain/settings/task-templates/use-task-template-editor.test.tsx b/src/test/domain/settings/task-templates/use-task-template-editor.test.tsx
new file mode 100644
index 00000000..6d292bd9
--- /dev/null
+++ b/src/test/domain/settings/task-templates/use-task-template-editor.test.tsx
@@ -0,0 +1,173 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import { act, renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { taskTemplatesApi } from "@/domain/settings/task-templates/api/task-templates-api";
+import type { TaskTemplate } from "@/domain/settings/task-templates/types/task-template";
+import { useTaskTemplateEditor } from "@/domain/settings/task-templates/use-cases/use-task-template-editor";
+import { createTestQueryClient } from "@/test/test-utils";
+
+const existingTemplate: TaskTemplate = {
+ id: "5",
+ name: "HVAC Inspection",
+ description: "Standard checklist",
+ isActive: true,
+ items: [
+ { itemText: "Check filters", sortOrder: 0 },
+ { itemText: "Check refrigerant", sortOrder: 1 },
+ ],
+};
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+function wrapperFor() {
+ const queryClient = createTestQueryClient();
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+ };
+}
+
+function renderEditor() {
+ vi.spyOn(taskTemplatesApi, "getAll").mockResolvedValue([existingTemplate]);
+ return renderHook(() => useTaskTemplateEditor(), { wrapper: wrapperFor() });
+}
+
+describe("useTaskTemplateEditor", () => {
+ it("starts with nothing selected and an empty form", async () => {
+ const { result } = renderEditor();
+
+ await waitFor(() => expect(result.current.templates).toHaveLength(1));
+ expect(result.current.selectedId).toBeNull();
+ expect(result.current.isNew).toBe(false);
+ expect(result.current.form.getValues()).toEqual({ name: "", description: "", items: [] });
+ });
+
+ it("selectTemplate loads the mapped form values for that template", async () => {
+ const { result } = renderEditor();
+ await waitFor(() => expect(result.current.templates).toHaveLength(1));
+
+ act(() => result.current.selectTemplate(existingTemplate));
+
+ expect(result.current.selectedId).toBe("5");
+ expect(result.current.isNew).toBe(false);
+ expect(result.current.form.getValues()).toEqual({
+ name: "HVAC Inspection",
+ description: "Standard checklist",
+ items: [
+ { text: "Check filters", sortOrder: 0 },
+ { text: "Check refrigerant", sortOrder: 1 },
+ ],
+ });
+ });
+
+ it("startNewTemplate marks isNew and resets the form to empty", async () => {
+ const { result } = renderEditor();
+ await waitFor(() => expect(result.current.templates).toHaveLength(1));
+ act(() => result.current.selectTemplate(existingTemplate));
+
+ act(() => result.current.startNewTemplate());
+
+ expect(result.current.isNew).toBe(true);
+ expect(result.current.form.getValues()).toEqual({ name: "", description: "", items: [] });
+ });
+
+ it("does not call create when the name is blank (schema validation blocks submit)", async () => {
+ const createSpy = vi.spyOn(taskTemplatesApi, "create");
+ const { result } = renderEditor();
+ await waitFor(() => expect(result.current.templates).toHaveLength(1));
+
+ act(() => result.current.startNewTemplate());
+ await act(async () => result.current.submitForm());
+
+ expect(createSpy).not.toHaveBeenCalled();
+ });
+
+ it("creates a new template and normalizes item sortOrder by array position", async () => {
+ const created: TaskTemplate = { ...existingTemplate, id: "9", name: "New Template" };
+ const createSpy = vi.spyOn(taskTemplatesApi, "create").mockResolvedValue(created);
+ const { result } = renderEditor();
+ await waitFor(() => expect(result.current.templates).toHaveLength(1));
+
+ act(() => result.current.startNewTemplate());
+ act(() => {
+ result.current.form.setValue("name", "New Template", { shouldValidate: true });
+ result.current.form.setValue(
+ "items",
+ [
+ { text: "Second", sortOrder: 99 },
+ { text: "First", sortOrder: 1 },
+ ],
+ { shouldValidate: true },
+ );
+ });
+
+ await act(async () => result.current.submitForm());
+
+ expect(createSpy).toHaveBeenCalledWith({
+ name: "New Template",
+ description: "",
+ items: [
+ { text: "Second", sortOrder: 0 },
+ { text: "First", sortOrder: 1 },
+ ],
+ });
+ await waitFor(() => expect(result.current.selectedId).toBe("9"));
+ });
+
+ it("updates the selected template instead of creating when not new", async () => {
+ const updateSpy = vi.spyOn(taskTemplatesApi, "update").mockResolvedValue(existingTemplate);
+ const { result } = renderEditor();
+ await waitFor(() => expect(result.current.templates).toHaveLength(1));
+
+ act(() => result.current.selectTemplate(existingTemplate));
+ act(() => {
+ result.current.form.setValue("description", "Updated description", { shouldValidate: true });
+ });
+
+ await act(async () => result.current.submitForm());
+
+ expect(updateSpy).toHaveBeenCalledWith(
+ "5",
+ expect.objectContaining({ description: "Updated description" }),
+ );
+ });
+
+ it("toggles the delete confirmation dialog open and closed", async () => {
+ const { result } = renderEditor();
+ await waitFor(() => expect(result.current.templates).toHaveLength(1));
+
+ act(() => result.current.requestDelete());
+ expect(result.current.deleteConfirmOpen).toBe(true);
+
+ act(() => result.current.cancelDelete());
+ expect(result.current.deleteConfirmOpen).toBe(false);
+ });
+
+ it("confirmDelete removes the template and clears the selection", async () => {
+ const deleteSpy = vi.spyOn(taskTemplatesApi, "delete").mockResolvedValue(undefined);
+ const { result } = renderEditor();
+ await waitFor(() => expect(result.current.templates).toHaveLength(1));
+
+ act(() => result.current.selectTemplate(existingTemplate));
+ act(() => result.current.requestDelete());
+
+ await act(async () => result.current.confirmDelete());
+
+ expect(deleteSpy).toHaveBeenCalledWith("5");
+ await waitFor(() => expect(result.current.selectedId).toBeNull());
+ expect(result.current.deleteConfirmOpen).toBe(false);
+ expect(result.current.form.getValues()).toEqual({ name: "", description: "", items: [] });
+ });
+
+ it("does nothing on confirmDelete when nothing is selected", async () => {
+ const deleteSpy = vi.spyOn(taskTemplatesApi, "delete");
+ const { result } = renderEditor();
+ await waitFor(() => expect(result.current.templates).toHaveLength(1));
+
+ await act(async () => result.current.confirmDelete());
+
+ expect(deleteSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/test/domain/vendor-portal/use-cases/use-vendor-portal-session.test.tsx b/src/test/domain/vendor-portal/use-cases/use-vendor-portal-session.test.tsx
new file mode 100644
index 00000000..bd924f33
--- /dev/null
+++ b/src/test/domain/vendor-portal/use-cases/use-vendor-portal-session.test.tsx
@@ -0,0 +1,101 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, waitFor } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
+import type { VendorPortalSession } from "@/domain/vendor-portal/types/vendor-portal";
+import { useVendorPortalSession } from "@/domain/vendor-portal/use-cases/use-vendor-portal-session";
+import { queryKeys } from "@/infra/query-key/query-key";
+import { createTestQueryClient } from "@/test/test-utils";
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+function wrapperFor(queryClient = createTestQueryClient()) {
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return {children};
+ };
+}
+
+describe("useVendorPortalSession", () => {
+ it("starts in loading status with a null vendor", () => {
+ vi.spyOn(vendorPortalApi, "session").mockReturnValue(new Promise(() => {}));
+
+ const { result } = renderHook(() => useVendorPortalSession("token-1"), {
+ wrapper: wrapperFor(),
+ });
+
+ expect(result.current.status).toBe("loading");
+ expect(result.current.vendor).toBeNull();
+ expect(result.current.error).toBeNull();
+ });
+
+ it("maps a successful fetch to ready status with the vendor payload", async () => {
+ const vendor: VendorPortalSession = { companyName: "Acme Vendor", vendorId: 7 };
+ vi.spyOn(vendorPortalApi, "session").mockResolvedValue(vendor);
+
+ const { result } = renderHook(() => useVendorPortalSession("token-1"), {
+ wrapper: wrapperFor(),
+ });
+
+ await waitFor(() => expect(result.current.status).toBe("ready"));
+ expect(result.current.vendor).toEqual(vendor);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("maps a rejected fetch to error status with the error preserved", async () => {
+ vi.spyOn(vendorPortalApi, "session").mockRejectedValue(new Error("Invalid link"));
+
+ const { result } = renderHook(() => useVendorPortalSession("token-1"), {
+ wrapper: wrapperFor(),
+ });
+
+ await waitFor(() => expect(result.current.status).toBe("error"));
+ expect(result.current.vendor).toBeNull();
+ expect(result.current.error).toBeInstanceOf(Error);
+ expect(result.current.error?.message).toBe("Invalid link");
+ });
+
+ it("caches by the centralized vendorPortal.session query key, keyed per token", async () => {
+ const sessionSpy = vi
+ .spyOn(vendorPortalApi, "session")
+ .mockResolvedValue({ companyName: "Acme Vendor" });
+ // staleTime: Infinity isolates cache-reuse from React Query's default
+ // background-refetch-on-mount behavior, which is a separate concern.
+ const queryClient = createTestQueryClient();
+ queryClient.setDefaultOptions({ queries: { retry: false, gcTime: 0, staleTime: Infinity } });
+ const wrapper = wrapperFor(queryClient);
+
+ const { result: first } = renderHook(() => useVendorPortalSession("shared-token"), { wrapper });
+ await waitFor(() => expect(first.current.status).toBe("ready"));
+
+ const { result: second } = renderHook(() => useVendorPortalSession("shared-token"), {
+ wrapper,
+ });
+ await waitFor(() => expect(second.current.status).toBe("ready"));
+
+ expect(sessionSpy).toHaveBeenCalledTimes(1);
+ expect(queryClient.getQueryData(queryKeys.vendorPortal.session("shared-token"))).toEqual({
+ companyName: "Acme Vendor",
+ });
+ });
+
+ it("fetches independently for a different token instead of reusing the cache", async () => {
+ const sessionSpy = vi
+ .spyOn(vendorPortalApi, "session")
+ .mockResolvedValue({ companyName: "Acme Vendor" });
+ const queryClient = createTestQueryClient();
+ const wrapper = wrapperFor(queryClient);
+
+ const { result: first } = renderHook(() => useVendorPortalSession("token-x"), { wrapper });
+ await waitFor(() => expect(first.current.status).toBe("ready"));
+
+ const { result: second } = renderHook(() => useVendorPortalSession("token-y"), { wrapper });
+ await waitFor(() => expect(second.current.status).toBe("ready"));
+
+ expect(sessionSpy).toHaveBeenCalledTimes(2);
+ expect(sessionSpy).toHaveBeenNthCalledWith(1, "token-x");
+ expect(sessionSpy).toHaveBeenNthCalledWith(2, "token-y");
+ });
+});