feat: sales opportunity detail, procurement filters, permission resilience
- 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
This commit is contained in:
@@ -174,7 +174,12 @@ describe("optima api modules", () => {
|
||||
.mockResolvedValueOnce({ data: { data: [] } })
|
||||
.mockResolvedValueOnce({ data: { data: { count: 4 } } });
|
||||
|
||||
await procurement.fetchMany("token", 3, "switch", 10, true);
|
||||
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", {
|
||||
@@ -208,6 +213,106 @@ describe("optima api modules", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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 } });
|
||||
@@ -237,6 +342,144 @@ describe("optima api modules", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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: [] } } });
|
||||
|
||||
Reference in New Issue
Block a user