From 89ad1c521fbbbeb13d8759d6fe0fbb0c2ecf8f5d Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Thu, 30 Apr 2026 23:17:56 -0400 Subject: [PATCH] fix(api): parse price filters as dollars --- packages/api-server/src/routes/ebay.ts | 11 +-- packages/api-server/src/routes/helpers.ts | 17 +++++ packages/api-server/src/routes/kijiji.ts | 11 +-- packages/api-server/test/routes.test.ts | 88 +++++++++++++++++------ 4 files changed, 91 insertions(+), 36 deletions(-) diff --git a/packages/api-server/src/routes/ebay.ts b/packages/api-server/src/routes/ebay.ts index bad0225..f3d0dcc 100644 --- a/packages/api-server/src/routes/ebay.ts +++ b/packages/api-server/src/routes/ebay.ts @@ -3,6 +3,7 @@ import { logger } from "../logger"; import { emptySearchResponse, getRequiredSearchQuery, + parseDollarPriceParam, parseNonNegativeIntegerParam, } from "./helpers"; @@ -18,17 +19,11 @@ export async function ebayRoute(req: Request): Promise { return SEARCH_QUERY; } - const minPrice = parseNonNegativeIntegerParam( - reqUrl.searchParams, - "minPrice", - ); + const minPrice = parseDollarPriceParam(reqUrl.searchParams, "minPrice"); if (minPrice instanceof Response) { return minPrice; } - const maxPrice = parseNonNegativeIntegerParam( - reqUrl.searchParams, - "maxPrice", - ); + const maxPrice = parseDollarPriceParam(reqUrl.searchParams, "maxPrice"); if (maxPrice instanceof Response) { return maxPrice; } diff --git a/packages/api-server/src/routes/helpers.ts b/packages/api-server/src/routes/helpers.ts index f5c4ecf..0c495d0 100644 --- a/packages/api-server/src/routes/helpers.ts +++ b/packages/api-server/src/routes/helpers.ts @@ -39,6 +39,23 @@ export function parseNonNegativeIntegerParam( return Number(rawValue); } +export function parseDollarPriceParam( + searchParams: URLSearchParams, + name: string, +): number | undefined | Response { + const rawValue = searchParams.get(name); + if (rawValue === null) { + return undefined; + } + if (!/^\d+(?:\.\d{1,2})?$/.test(rawValue)) { + return Response.json( + { message: `Invalid ${name} parameter` }, + { status: 400 }, + ); + } + return Math.round(Number(rawValue) * 100); +} + export function emptySearchResponse(hint?: string): Response { const message = hint ? `Search didn't return any results! ${hint}` diff --git a/packages/api-server/src/routes/kijiji.ts b/packages/api-server/src/routes/kijiji.ts index ad6affb..7f63aa8 100644 --- a/packages/api-server/src/routes/kijiji.ts +++ b/packages/api-server/src/routes/kijiji.ts @@ -3,6 +3,7 @@ import { logger } from "../logger"; import { emptySearchResponse, getRequiredSearchQuery, + parseDollarPriceParam, parseNonNegativeIntegerParam, } from "./helpers"; @@ -26,17 +27,11 @@ export async function kijijiRoute(req: Request): Promise { if (maxPages instanceof Response) { return maxPages; } - const priceMin = parseNonNegativeIntegerParam( - reqUrl.searchParams, - "priceMin", - ); + const priceMin = parseDollarPriceParam(reqUrl.searchParams, "priceMin"); if (priceMin instanceof Response) { return priceMin; } - const priceMax = parseNonNegativeIntegerParam( - reqUrl.searchParams, - "priceMax", - ); + const priceMax = parseDollarPriceParam(reqUrl.searchParams, "priceMax"); if (priceMax instanceof Response) { return priceMax; } diff --git a/packages/api-server/test/routes.test.ts b/packages/api-server/test/routes.test.ts index 5958c9d..77a2e7c 100644 --- a/packages/api-server/test/routes.test.ts +++ b/packages/api-server/test/routes.test.ts @@ -282,6 +282,24 @@ describe("API routes", () => { ); }); + test("kijijiRoute forwards dollar price filters to core as cents", async () => { + const { kijijiRoute } = await import("../src/routes/kijiji"); + + await kijijiRoute( + new Request( + "http://localhost/api/kijiji?q=laptop&priceMin=999.99&priceMax=1000", + ), + ); + + expect(fetchKijijiItems).toHaveBeenCalledWith( + "laptop", + 4, + "https://www.kijiji.ca", + expect.objectContaining({ priceMin: 99_999, priceMax: 100_000 }), + {}, + ); + }); + test("kijijiRoute does not forward unstableFilter when false", async () => { const { kijijiRoute } = await import("../src/routes/kijiji"); @@ -414,6 +432,24 @@ describe("API routes", () => { ); }); + test("ebayRoute forwards dollar price filters to core as cents", async () => { + const { ebayRoute } = await import("../src/routes/ebay"); + + fetchEbayItems.mockImplementation(() => Promise.resolve([{ title: "a" }])); + + await ebayRoute( + new Request( + "http://localhost/api/ebay?q=macbook&minPrice=999.99&maxPrice=1000", + ), + ); + + expect(fetchEbayItems).toHaveBeenCalledWith( + "macbook", + 1, + expect.objectContaining({ minPrice: 99_999, maxPrice: 100_000 }), + ); + }); + test("ebayRoute passes through scraper payload unchanged in unstable mode", async () => { const { ebayRoute } = await import("../src/routes/ebay"); @@ -730,16 +766,18 @@ describe("API routes", () => { expect(body.message).toBe("Invalid minPrice parameter"); }); - test("ebayRoute returns 400 for decimal minPrice", async () => { + test("ebayRoute accepts decimal minPrice", async () => { const { ebayRoute } = await import("../src/routes/ebay"); - const response = await ebayRoute( + await ebayRoute( new Request("http://localhost/api/ebay?q=laptop&minPrice=1.5"), ); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.message).toBe("Invalid minPrice parameter"); + expect(fetchEbayItems).toHaveBeenCalledWith( + "laptop", + 1, + expect.objectContaining({ minPrice: 150 }), + ); }); test("ebayRoute returns 400 for non-integer maxPrice", async () => { @@ -766,16 +804,18 @@ describe("API routes", () => { expect(body.message).toBe("Invalid maxPrice parameter"); }); - test("ebayRoute returns 400 for decimal maxPrice", async () => { + test("ebayRoute accepts decimal maxPrice", async () => { const { ebayRoute } = await import("../src/routes/ebay"); - const response = await ebayRoute( + await ebayRoute( new Request("http://localhost/api/ebay?q=laptop&maxPrice=1.5"), ); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.message).toBe("Invalid maxPrice parameter"); + expect(fetchEbayItems).toHaveBeenCalledWith( + "laptop", + 1, + expect.objectContaining({ maxPrice: 150 }), + ); }); test("kijijiRoute returns 400 for decimal maxPages", async () => { @@ -862,16 +902,20 @@ describe("API routes", () => { expect(body.message).toBe("Invalid priceMin parameter"); }); - test("kijijiRoute returns 400 for decimal priceMin", async () => { + test("kijijiRoute accepts decimal priceMin", async () => { const { kijijiRoute } = await import("../src/routes/kijiji"); - const response = await kijijiRoute( + await kijijiRoute( new Request("http://localhost/api/kijiji?q=laptop&priceMin=1.5"), ); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.message).toBe("Invalid priceMin parameter"); + expect(fetchKijijiItems).toHaveBeenCalledWith( + "laptop", + 4, + "https://www.kijiji.ca", + expect.objectContaining({ priceMin: 150 }), + {}, + ); }); test("kijijiRoute returns 400 for non-integer priceMin", async () => { @@ -934,16 +978,20 @@ describe("API routes", () => { expect(body.message).toBe("Invalid priceMax parameter"); }); - test("kijijiRoute returns 400 for decimal priceMax", async () => { + test("kijijiRoute accepts decimal priceMax", async () => { const { kijijiRoute } = await import("../src/routes/kijiji"); - const response = await kijijiRoute( + await kijijiRoute( new Request("http://localhost/api/kijiji?q=laptop&priceMax=1.5"), ); - expect(response.status).toBe(400); - const body = await response.json(); - expect(body.message).toBe("Invalid priceMax parameter"); + expect(fetchKijijiItems).toHaveBeenCalledWith( + "laptop", + 4, + "https://www.kijiji.ca", + expect.objectContaining({ priceMax: 150 }), + {}, + ); }); test("kijijiRoute returns 400 for non-integer priceMax", async () => {