4bec198db6
- Add sales opportunity detail page with tabs (overview, notes, contacts, products, forecasts, activity) - Add sales note CRUD endpoints (create, update, delete) with server routes - Add opportunity types, contacts, product sequencing, and refresh API methods - Add AddProductModal component for catalog browsing - Update procurement.fetchMany to accept CatalogItemFilters object - Add procurement.fetchCategories and procurement.fetchFilters endpoints - Add resilient permission check (no-token returns all-true with __checkFailed) - Parallelize company detail data fetches for performance - Remove stale console.log statements across modules - Add comprehensive unit tests for all new API methods and permission edge cases
545 lines
17 KiB
TypeScript
545 lines
17 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { mockApi } = vi.hoisted(() => ({
|
|
mockApi: {
|
|
get: vi.fn(),
|
|
post: vi.fn(),
|
|
patch: vi.fn(),
|
|
put: vi.fn(),
|
|
delete: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../axios", () => ({
|
|
default: mockApi,
|
|
api: mockApi,
|
|
}));
|
|
|
|
import { company } from "./companies";
|
|
import { credential } from "./credentials";
|
|
import { credentialType } from "./credentialTypes";
|
|
import { permission } from "./permissions";
|
|
import { procurement } from "./procurement";
|
|
import { role } from "./roles";
|
|
import { sales } from "./sales";
|
|
import { unifi } from "./unifi";
|
|
import { users } from "./users";
|
|
|
|
describe("optima api modules", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("company.fetchMany sends search params", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
|
|
|
await company.fetchMany("token", 2, "acme", 50);
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/company/companies", {
|
|
params: { page: 2, rpp: 50, search: "acme" },
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
});
|
|
|
|
it("company.count returns count", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: { count: 17 } } });
|
|
|
|
const count = await company.count("token");
|
|
|
|
expect(count).toBe(17);
|
|
});
|
|
|
|
it("credential.fetch and delete call expected routes", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: { id: "cred-1" } } });
|
|
mockApi.delete.mockResolvedValueOnce({ data: { ok: true } });
|
|
|
|
await credential.fetch("token", "cred-1");
|
|
await credential.delete("token", "cred-1");
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith(
|
|
"/v1/credential/credentials/cred-1",
|
|
{
|
|
headers: { Authorization: "Bearer token" },
|
|
},
|
|
);
|
|
expect(mockApi.delete).toHaveBeenCalledWith(
|
|
"/v1/credential/credentials/cred-1",
|
|
{
|
|
headers: { Authorization: "Bearer token" },
|
|
},
|
|
);
|
|
});
|
|
|
|
it("credential.create posts payload", async () => {
|
|
const payload = {
|
|
name: "VPN",
|
|
notes: "notes",
|
|
typeId: "type-1",
|
|
companyId: "company-1",
|
|
fields: [],
|
|
};
|
|
mockApi.post.mockResolvedValueOnce({ data: { data: payload } });
|
|
|
|
await credential.create("token", payload as any);
|
|
|
|
expect(mockApi.post).toHaveBeenCalledWith(
|
|
"/v1/credential/credentials",
|
|
payload,
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("credential.updateFields wraps fields payload", async () => {
|
|
const fields = [{ id: "f1", name: "Username" }];
|
|
mockApi.put.mockResolvedValueOnce({ data: { ok: true } });
|
|
|
|
await credential.updateFields("token", "cred-1", fields as any);
|
|
|
|
expect(mockApi.put).toHaveBeenCalledWith(
|
|
"/v1/credential/credentials/cred-1/fields",
|
|
{ fields },
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("credential sub-credential methods call expected endpoints", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
|
mockApi.post.mockResolvedValueOnce({ data: { ok: true } });
|
|
mockApi.delete.mockResolvedValueOnce({ data: { ok: true } });
|
|
|
|
await credential.fetchSubCredentials("token", "cred-1");
|
|
await credential.addSubCredential("token", "cred-1", {
|
|
fieldId: "fid",
|
|
name: "Sub",
|
|
fields: [{ fieldId: "f2", value: "v" }],
|
|
});
|
|
await credential.removeSubCredential("token", "cred-1", "sub-1");
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith(
|
|
"/v1/credential/credentials/cred-1/sub-credentials",
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
expect(mockApi.post).toHaveBeenCalledWith(
|
|
"/v1/credential/credentials/cred-1/sub-credentials",
|
|
{ fieldId: "fid", name: "Sub", fields: [{ fieldId: "f2", value: "v" }] },
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
expect(mockApi.delete).toHaveBeenCalledWith(
|
|
"/v1/credential/credentials/cred-1/sub-credentials/sub-1",
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("credentialType create and delete use identifier path", async () => {
|
|
mockApi.post.mockResolvedValueOnce({ data: { ok: true } });
|
|
mockApi.delete.mockResolvedValueOnce({ data: { ok: true } });
|
|
|
|
await credentialType.create("token", {
|
|
name: "Router",
|
|
permissionScope: "router",
|
|
fields: [],
|
|
} as any);
|
|
await credentialType.delete("token", "ctype-1");
|
|
|
|
expect(mockApi.post).toHaveBeenCalledWith(
|
|
"/v1/credential-type",
|
|
{ name: "Router", permissionScope: "router", fields: [] },
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
expect(mockApi.delete).toHaveBeenCalledWith("/v1/credential-type/ctype-1", {
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
});
|
|
|
|
it("permission module endpoints are correct", async () => {
|
|
mockApi.get.mockResolvedValue({ data: { data: [] } });
|
|
|
|
await permission.fetchCategorized("token");
|
|
await permission.fetchFlat("token");
|
|
await permission.fetchByCategory("token", "company");
|
|
|
|
expect(mockApi.get).toHaveBeenNthCalledWith(1, "/v1/permissions", {
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
expect(mockApi.get).toHaveBeenNthCalledWith(2, "/v1/permissions/nodes", {
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
expect(mockApi.get).toHaveBeenNthCalledWith(3, "/v1/permissions/company", {
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
});
|
|
|
|
it("procurement methods build params and count correctly", async () => {
|
|
mockApi.get
|
|
.mockResolvedValueOnce({ data: { data: [] } })
|
|
.mockResolvedValueOnce({ data: { data: { count: 4 } } });
|
|
|
|
await procurement.fetchMany(
|
|
"token",
|
|
3,
|
|
{ search: "switch", includeInactive: true },
|
|
10,
|
|
);
|
|
const count = await procurement.count("token", true);
|
|
|
|
expect(mockApi.get).toHaveBeenNthCalledWith(1, "/v1/procurement/items", {
|
|
params: { page: 3, rpp: 10, search: "switch", includeInactive: true },
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
expect(mockApi.get).toHaveBeenNthCalledWith(2, "/v1/procurement/count", {
|
|
params: { activeOnly: "true" },
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
expect(count).toBe(4);
|
|
});
|
|
|
|
it("procurement link and unlink post target id", async () => {
|
|
mockApi.post.mockResolvedValue({ data: { ok: true } });
|
|
|
|
await procurement.linkItem("token", "item-a", "item-b");
|
|
await procurement.unlinkItem("token", "item-a", "item-b");
|
|
|
|
expect(mockApi.post).toHaveBeenNthCalledWith(
|
|
1,
|
|
"/v1/procurement/items/item-a/link",
|
|
{ targetId: "item-b" },
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
expect(mockApi.post).toHaveBeenNthCalledWith(
|
|
2,
|
|
"/v1/procurement/items/item-a/unlink",
|
|
{ targetId: "item-b" },
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("procurement.fetchMany passes all CatalogItemFilters as params", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
|
|
|
await procurement.fetchMany("token", 1, {
|
|
search: "cable",
|
|
category: "Networking",
|
|
subcategory: "Ethernet",
|
|
group: "Cat6",
|
|
manufacturer: "Ubiquiti",
|
|
ecosystem: "unifi",
|
|
inStock: true,
|
|
minPrice: 10,
|
|
maxPrice: 500,
|
|
includeInactive: true,
|
|
});
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/items", {
|
|
params: {
|
|
page: 1,
|
|
rpp: 30,
|
|
search: "cable",
|
|
category: "Networking",
|
|
subcategory: "Ethernet",
|
|
group: "Cat6",
|
|
manufacturer: "Ubiquiti",
|
|
ecosystem: "unifi",
|
|
inStock: true,
|
|
minPrice: 10,
|
|
maxPrice: 500,
|
|
includeInactive: true,
|
|
},
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
});
|
|
|
|
it("procurement.fetchMany omits filters that are not set", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
|
|
|
await procurement.fetchMany("token", 2, { category: "Audio" }, 15);
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/items", {
|
|
params: { page: 2, rpp: 15, category: "Audio" },
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
});
|
|
|
|
it("procurement.fetchCategories returns category tree", async () => {
|
|
const tree = { categories: [{ name: "Net" }], ecosystems: [] };
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: tree } });
|
|
|
|
const result = await procurement.fetchCategories("token");
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/categories", {
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
expect(result).toEqual(tree);
|
|
});
|
|
|
|
it("procurement.fetchFilters passes optional params", async () => {
|
|
const filterValues = {
|
|
categories: ["A"],
|
|
subcategories: ["B"],
|
|
manufacturers: ["C"],
|
|
};
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: filterValues } });
|
|
|
|
const result = await procurement.fetchFilters("token", {
|
|
category: "Networking",
|
|
subcategory: "Switches",
|
|
includeInactive: true,
|
|
});
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/filters", {
|
|
params: {
|
|
category: "Networking",
|
|
subcategory: "Switches",
|
|
includeInactive: "true",
|
|
},
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
expect(result).toEqual(filterValues);
|
|
});
|
|
|
|
it("procurement.fetchFilters works with no options", async () => {
|
|
const filterValues = {
|
|
categories: [],
|
|
subcategories: [],
|
|
manufacturers: [],
|
|
};
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: filterValues } });
|
|
|
|
const result = await procurement.fetchFilters("token");
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/procurement/filters", {
|
|
params: {},
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
expect(result).toEqual(filterValues);
|
|
});
|
|
|
|
it("role add and remove permissions include payload", async () => {
|
|
mockApi.post.mockResolvedValueOnce({ data: { ok: true } });
|
|
mockApi.delete.mockResolvedValueOnce({ data: { ok: true } });
|
|
|
|
await role.addPermissions("token", "role-1", ["a", "b"]);
|
|
await role.removePermissions("token", "role-1", ["a"]);
|
|
|
|
expect(mockApi.post).toHaveBeenCalledWith(
|
|
"/v1/role/role-1/permissions",
|
|
{ permissions: ["a", "b"] },
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
expect(mockApi.delete).toHaveBeenCalledWith("/v1/role/role-1/permissions", {
|
|
headers: { Authorization: "Bearer token" },
|
|
data: { permissions: ["a"] },
|
|
});
|
|
});
|
|
|
|
it("sales.fetchMany includes includeClosed and search params", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
|
|
|
await sales.fetchMany("token", 1, "fiber", 25, true);
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/sales/opportunities", {
|
|
params: { page: 1, rpp: 25, search: "fiber", includeClosed: true },
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
});
|
|
|
|
it("sales.fetchOpportunityTypes calls expected endpoint", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
|
|
|
await sales.fetchOpportunityTypes("token");
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/sales/opportunity-types", {
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
});
|
|
|
|
it("sales.fetchOne encodes identifier in URL", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: { id: "opp-1" } } });
|
|
|
|
await sales.fetchOne("token", "opp-1");
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith(
|
|
"/v1/sales/opportunities/opp-1",
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("sales.fetchForecasts calls forecasts endpoint", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
|
|
|
await sales.fetchForecasts("token", "opp-1");
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith(
|
|
"/v1/sales/opportunities/opp-1/forecasts",
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("sales.fetchProducts calls products endpoint", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
|
|
|
await sales.fetchProducts("token", "opp-1");
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith(
|
|
"/v1/sales/opportunities/opp-1/products",
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("sales.fetchNotes calls notes endpoint", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
|
|
|
await sales.fetchNotes("token", "opp-1");
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith(
|
|
"/v1/sales/opportunities/opp-1/notes",
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("sales.createNote posts note payload", async () => {
|
|
mockApi.post.mockResolvedValueOnce({ data: { id: 1, text: "Hello" } });
|
|
|
|
await sales.createNote("token", "opp-1", {
|
|
text: "Hello",
|
|
flagged: true,
|
|
});
|
|
|
|
expect(mockApi.post).toHaveBeenCalledWith(
|
|
"/v1/sales/opportunities/opp-1/notes",
|
|
{ text: "Hello", flagged: true },
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("sales.updateNote patches with noteId in URL", async () => {
|
|
mockApi.patch.mockResolvedValueOnce({ data: { ok: true } });
|
|
|
|
await sales.updateNote("token", "opp-1", 42, { text: "Updated" });
|
|
|
|
expect(mockApi.patch).toHaveBeenCalledWith(
|
|
"/v1/sales/opportunities/opp-1/notes/42",
|
|
{ text: "Updated" },
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("sales.deleteNote calls delete with noteId in URL", async () => {
|
|
mockApi.delete.mockResolvedValueOnce({ data: { ok: true } });
|
|
|
|
await sales.deleteNote("token", "opp-1", 42);
|
|
|
|
expect(mockApi.delete).toHaveBeenCalledWith(
|
|
"/v1/sales/opportunities/opp-1/notes/42",
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("sales.fetchContacts calls contacts endpoint", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: [] } });
|
|
|
|
await sales.fetchContacts("token", "opp-1");
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith(
|
|
"/v1/sales/opportunities/opp-1/contacts",
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("sales.sequenceProducts patches with ordered IDs", async () => {
|
|
mockApi.patch.mockResolvedValueOnce({ data: { ok: true } });
|
|
|
|
await sales.sequenceProducts("token", "opp-1", [3, 1, 2]);
|
|
|
|
expect(mockApi.patch).toHaveBeenCalledWith(
|
|
"/v1/sales/opportunities/opp-1/products/sequence",
|
|
{ orderedIds: [3, 1, 2] },
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("sales.refreshOpportunity posts to refresh endpoint", async () => {
|
|
mockApi.post.mockResolvedValueOnce({ data: { ok: true } });
|
|
|
|
await sales.refreshOpportunity("token", "opp-1");
|
|
|
|
expect(mockApi.post).toHaveBeenCalledWith(
|
|
"/v1/sales/opportunities/opp-1/refresh",
|
|
{},
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("sales.fetchOne encodes special characters in identifier", async () => {
|
|
mockApi.get.mockResolvedValueOnce({ data: { data: {} } });
|
|
|
|
await sales.fetchOne("token", "opp/special#1");
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith(
|
|
`/v1/sales/opportunities/${encodeURIComponent("opp/special#1")}`,
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("users module uses expected endpoints", async () => {
|
|
mockApi.get.mockResolvedValue({ data: { data: [] } });
|
|
mockApi.post.mockResolvedValueOnce({ data: { data: { results: [] } } });
|
|
mockApi.patch.mockResolvedValueOnce({ data: { data: {} } });
|
|
mockApi.delete.mockResolvedValueOnce({ data: { data: {} } });
|
|
|
|
await users.fetchAll("token");
|
|
await users.fetch("token", "user-1");
|
|
await users.fetchRoles("token", "user-1");
|
|
await users.checkPermissions("token", "user-1", ["x"]);
|
|
await users.update("token", "user-1", { name: "New Name" });
|
|
await users.delete("token", "user-1");
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/user/users", {
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/user/users/user-1", {
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/user/users/user-1/roles", {
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
expect(mockApi.post).toHaveBeenCalledWith(
|
|
"/v1/user/users/user-1/check-permission",
|
|
{ permissions: ["x"] },
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
|
|
it("unifi module hits expected endpoints for site and wifi actions", async () => {
|
|
mockApi.get.mockResolvedValue({ data: { data: [] } });
|
|
mockApi.post.mockResolvedValue({ data: { ok: true } });
|
|
mockApi.patch.mockResolvedValue({ data: { ok: true } });
|
|
|
|
await unifi.fetchSites("token");
|
|
await unifi.syncSites("token");
|
|
await unifi.createSite("token", "HQ");
|
|
await unifi.linkSite("token", "site-1", "company-1");
|
|
await unifi.unlinkSite("token", "site-1");
|
|
await unifi.fetchSiteWifi("token", "site-1");
|
|
await unifi.updateWifi("token", "site-1", "wlan-1", { name: "New" });
|
|
await unifi.fetchPPSKs("token", "site-1", "wlan-1");
|
|
await unifi.createPPSK("token", "site-1", "wlan-1", {
|
|
key: "abc",
|
|
name: "Staff",
|
|
});
|
|
|
|
expect(mockApi.get).toHaveBeenCalledWith("/v1/unifi/sites", {
|
|
headers: { Authorization: "Bearer token" },
|
|
});
|
|
expect(mockApi.post).toHaveBeenCalledWith(
|
|
"/v1/unifi/sites/sync",
|
|
{},
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
expect(mockApi.patch).toHaveBeenCalledWith(
|
|
"/v1/unifi/site/site-1/wifi/wlan-1",
|
|
{ name: "New" },
|
|
{ headers: { Authorization: "Bearer token" } },
|
|
);
|
|
});
|
|
});
|