Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 693184244e Fix null product query parameters 2026-07-06 19:56:07 +02:00
4 changed files with 181 additions and 26 deletions
@@ -32,6 +32,23 @@ const normalizeDateTimeLocalValue = (value) => {
return `${datePart}T${timePart.slice(0, 5)}`; return `${datePart}T${timePart.slice(0, 5)}`;
}; };
const sanitizeQueryParams = (params = {}) => {
if (!params || typeof params !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== null && value !== undefined)
);
};
const buildQueryString = (params = {}) => {
const queryParams = sanitizeQueryParams(params);
const queryString = new URLSearchParams(queryParams).toString();
return queryString ? `?${queryString}` : '';
};
/** /**
* The Global Objects object, which contains global functions for objects. * The Global Objects object, which contains global functions for objects.
*/ */
@@ -431,20 +448,15 @@ export const ObjectsGlobal = {
* @returns {Promise} The promise * @returns {Promise} The promise
*/ */
object: async (endpoint, id, optionsObject = {}) => { object: async (endpoint, id, optionsObject = {}) => {
const cacheKey = JSON.stringify({endpoint, id, optionsObject}); const requestParams = typeof id === 'object' && id !== null
? sanitizeQueryParams(id)
: {id};
const cacheKey = JSON.stringify({endpoint, id: requestParams, optionsObject});
if (ObjectsGlobal.cache.has(cacheKey)) { if (ObjectsGlobal.cache.has(cacheKey)) {
return ObjectsGlobal.cache.get(cacheKey); return ObjectsGlobal.cache.get(cacheKey);
} }
let requestEndpoint = endpoint; const requestEndpoint = `${endpoint}${buildQueryString(requestParams)}`;
if (typeof id === 'object') {
// If the id is an object, we need to convert it to a query string
const params = new URLSearchParams(id).toString();
requestEndpoint = `${endpoint}?${params}`;
} else {
// If the id is a number, we can just append it to the endpoint
requestEndpoint = `${endpoint}?id=${id}`;
}
let options = ObjectsGlobal.requestOptions.applyOptions(optionsObject); let options = ObjectsGlobal.requestOptions.applyOptions(optionsObject);
let promise; let promise;
if (options.authenticated) { if (options.authenticated) {
@@ -499,23 +511,24 @@ export const ObjectsGlobal = {
* @returns {Promise} The promise * @returns {Promise} The promise
*/ */
objects: async (endpoint, data = {}) => { objects: async (endpoint, data = {}) => {
const cacheKey = JSON.stringify({endpoint, data}); const requestData = sanitizeQueryParams(data);
if (ObjectsGlobal.cache.has(cacheKey)) { if (requestData.filters && typeof requestData.filters === 'object') {
return ObjectsGlobal.cache.get(cacheKey); requestData.filters = Object.entries(requestData.filters).flatMap(([key, value]) => {
}
if (data.filters && typeof data.filters === 'object') {
data.filters = Object.entries(data.filters).flatMap(([key, value]) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.map(v => `${key}:${v}`); return value.map(v => `${key}:${v}`);
} }
return `${key}:${value}`; return `${key}:${value}`;
}).join(','); }).join(',');
} }
const cacheKey = JSON.stringify({endpoint, data: requestData});
if (ObjectsGlobal.cache.has(cacheKey)) {
return ObjectsGlobal.cache.get(cacheKey);
}
const promise = authenticatedRequest( const promise = authenticatedRequest(
endpoint, endpoint,
"GET", "GET",
data requestData
).then((response) => { ).then((response) => {
return response.data.data; return response.data.data;
}).catch((error) => { }).catch((error) => {
@@ -8,6 +8,17 @@ import i18n from '@/i18n';
const t = (key) => i18n.global.t(key); const t = (key) => i18n.global.t(key);
const normalizeProductQueryOptions = (options = {}) => {
const queryOptions = { ...options };
if (!Object.prototype.hasOwnProperty.call(queryOptions, "final_price")) {
queryOptions.final_price = false;
}
return Object.fromEntries(
Object.entries(queryOptions).filter(([, value]) => value !== null && value !== undefined)
);
};
/** /**
* Local products cache * Local products cache
* @type {ref<null>} * @type {ref<null>}
@@ -269,19 +280,19 @@ export const Products = {
}, },
}, },
get: { get: {
all: async (options = {department_id: null, customer_id: null, category_id: null, final_price: false}) => { all: async (options = {}) => {
return ObjectsGlobal.get.objects(Products.meta.endpoint, {...options}); return ObjectsGlobal.get.objects(Products.meta.endpoint, normalizeProductQueryOptions(options));
}, },
single: async (id, options = {department_id: null, customer_id: null, category_id: null, final_price: false}) => { single: async (id, options = {}) => {
return ObjectsGlobal.get.object(Products.meta.endpoint, {id: id, ...options}); return ObjectsGlobal.get.object(Products.meta.endpoint, {id: id, ...normalizeProductQueryOptions(options)});
}, },
category: async (category_id, department_id, customer_id = null, final_price = false) => { category: async (category_id, department_id, customer_id = null, final_price = false) => {
return ObjectsGlobal.get.object(Products.meta.endpoint, { return ObjectsGlobal.get.object(Products.meta.endpoint, normalizeProductQueryOptions({
category: category_id, category: category_id,
department_id: department_id, department_id: department_id,
...(customer_id !== null ? {customer_id: customer_id} : {}), ...(customer_id !== null ? {customer_id: customer_id} : {}),
...{final_price: final_price} ...{final_price: final_price}
}); }));
}, },
}, },
delete: async (id) => { delete: async (id) => {
@@ -324,4 +335,4 @@ export const Products = {
); );
}, },
}; };
</script> </script>
+21 -1
View File
@@ -15,6 +15,7 @@ type ProductRouteOptions = {
categories?: unknown; categories?: unknown;
productOptions?: unknown; productOptions?: unknown;
economicProducts?: unknown; economicProducts?: unknown;
onProductsRequest?: (url: URL) => void;
}; };
const collectBrowserErrors = (page: Page) => { const collectBrowserErrors = (page: Page) => {
@@ -42,6 +43,7 @@ const stubSuperuserProductRoutes = async (
categories = [], categories = [],
productOptions = [], productOptions = [],
economicProducts = [], economicProducts = [],
onProductsRequest,
}: ProductRouteOptions }: ProductRouteOptions
) => { ) => {
await seedAuthenticatedState(page, token); await seedAuthenticatedState(page, token);
@@ -154,6 +156,7 @@ const stubSuperuserProductRoutes = async (
} }
if (pathname.endsWith("/products") && method === "GET") { if (pathname.endsWith("/products") && method === "GET") {
onProductsRequest?.(url);
const perPage = Number(url.searchParams.get("limit") || "100"); const perPage = Number(url.searchParams.get("limit") || "100");
await route.fulfill( await route.fulfill(
@@ -273,7 +276,14 @@ test.describe("Superuser products layout", () => {
]; ];
await page.setViewportSize({ width: 1280, height: 720 }); await page.setViewportSize({ width: 1280, height: 720 });
await stubSuperuserProductRoutes(page, { products, categories, productOptions, economicProducts }); const productRequestUrls: string[] = [];
await stubSuperuserProductRoutes(page, {
products,
categories,
productOptions,
economicProducts,
onProductsRequest: (url) => productRequestUrls.push(url.toString()),
});
await page.goto("/superuser/products"); await page.goto("/superuser/products");
@@ -316,6 +326,16 @@ test.describe("Superuser products layout", () => {
await expect(page.getByTestId("superuser-product-name")).toContainText("Trækker"); await expect(page.getByTestId("superuser-product-name")).toContainText("Trækker");
await expect(page.getByRole("heading", { name: "Trækker" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Trækker" })).toBeVisible();
const detailRequest = productRequestUrls
.map((url) => new URL(url))
.find((url) => url.searchParams.get("id") === "1");
expect(detailRequest).toBeDefined();
expect(detailRequest?.searchParams.get("department_id")).toBeNull();
expect(detailRequest?.searchParams.get("customer_id")).toBeNull();
expect(detailRequest?.searchParams.get("category_id")).toBeNull();
expect(detailRequest?.searchParams.get("final_price")).toBe("false");
expect(pageErrors).toEqual([]); expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]); expect(consoleErrors).toEqual([]);
}); });
@@ -25,14 +25,125 @@ vi.mock("@/components/session/token/SessionUser/Objects/systemUserIds.js", () =>
getSystemUserIds: () => [], getSystemUserIds: () => [],
})); }));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
objects: {
global: {
language: {
no_data: "No data",
},
},
products: {
get: {
all: vi.fn(),
},
},
},
},
}));
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue"; import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { Products } from "@/components/session/token/SessionUser/Objects/Products.vue";
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
ObjectsGlobal.clearCache();
document.body.innerHTML = ""; document.body.innerHTML = "";
}); });
describe("ObjectsGlobal query parameters", () => {
it("omits nullish object GET parameters while preserving false and zero", async () => {
authenticatedRequest.mockResolvedValueOnce({
data: {
data: {
id: 40,
},
},
});
await expect(
ObjectsGlobal.get.object("/products", {
id: 40,
department_id: null,
customer_id: undefined,
category_id: null,
final_price: false,
page: 0,
})
).resolves.toEqual({
id: 40,
});
expect(authenticatedRequest).toHaveBeenCalledWith("/products?id=40&final_price=false&page=0", "GET");
});
it("omits nullish object list parameters while preserving false", async () => {
authenticatedRequest.mockResolvedValueOnce({
data: {
data: [],
},
});
await expect(
ObjectsGlobal.get.objects("/products", {
department_id: null,
customer_id: undefined,
final_price: false,
})
).resolves.toEqual([]);
expect(authenticatedRequest).toHaveBeenCalledWith("/products", "GET", {
final_price: false,
});
});
});
describe("Products query parameters", () => {
it("omits nullish default pricing parameters for single product GETs", async () => {
authenticatedRequest.mockResolvedValueOnce({
data: {
data: {
id: 40,
},
},
});
await expect(Products.get.single(40)).resolves.toEqual({
id: 40,
});
expect(authenticatedRequest).toHaveBeenCalledWith("/products?id=40&final_price=false", "GET");
});
it("preserves explicit product pricing parameters", async () => {
authenticatedRequest.mockResolvedValueOnce({
data: {
data: {
id: 40,
},
},
});
await expect(
Products.get.single(40, {
department_id: 2,
customer_id: 12345,
category_id: 8,
final_price: true,
})
).resolves.toEqual({
id: 40,
});
expect(authenticatedRequest).toHaveBeenCalledWith(
"/products?id=40&department_id=2&customer_id=12345&category_id=8&final_price=true",
"GET"
);
});
});
describe("ObjectsGlobal select editor escaping", () => { describe("ObjectsGlobal select editor escaping", () => {
it("escapes option ids and names before rendering SweetAlert HTML", async () => { it("escapes option ids and names before rendering SweetAlert HTML", async () => {
const object = { const object = {