diff --git a/apps/api/src/modules/search/routes/public-search-routes.js b/apps/api/src/modules/search/routes/public-search-routes.js index 05b2cc4..9e5e2e7 100644 --- a/apps/api/src/modules/search/routes/public-search-routes.js +++ b/apps/api/src/modules/search/routes/public-search-routes.js @@ -1,12 +1,15 @@ import { createApplicationError } from '../../../shared/errors/application-error.js'; -function coerceNumber(value, fallback) { +function coerceNumber(value, fieldName, fallback) { if (value === undefined || value === null || value === '') { return fallback; } const parsed = Number.parseInt(String(value), 10); - return Number.isNaN(parsed) ? fallback : parsed; + if (Number.isNaN(parsed) || String(parsed) !== String(value)) { + throw createApplicationError('INVALID_SEARCH_FILTER', `${fieldName} must be an integer.`, 400); + } + return parsed; } export async function registerPublicSearchRoutes(app, options) { @@ -24,10 +27,10 @@ export async function registerPublicSearchRoutes(app, options) { const filters = { query: request.query.query, category: request.query.category, - minPriceMinor: coerceNumber(request.query.minPriceMinor, undefined), - maxPriceMinor: coerceNumber(request.query.maxPriceMinor, undefined), - page: coerceNumber(request.query.page, 1), - limit: coerceNumber(request.query.limit, 20), + minPriceMinor: coerceNumber(request.query.minPriceMinor, 'minPriceMinor', undefined), + maxPriceMinor: coerceNumber(request.query.maxPriceMinor, 'maxPriceMinor', undefined), + page: coerceNumber(request.query.page, 'page', 1), + limit: coerceNumber(request.query.limit, 'limit', 20), includeSold: request.query.includeSold === 'true' || request.query.includeSold === true, }; diff --git a/apps/api/tests/http/public-search-routes.test.js b/apps/api/tests/http/public-search-routes.test.js index bc2bd8a..95c1a60 100644 --- a/apps/api/tests/http/public-search-routes.test.js +++ b/apps/api/tests/http/public-search-routes.test.js @@ -32,4 +32,17 @@ test('returns public search results with pagination metadata', async (testContex totalPages: 2, }); assert.equal(response.json().data.items[0].id, 'listing-1'); +}); + +test('rejects malformed numeric search filters', async (testContext) => { + const app = buildApp({ searchService: { async searchPublic() { throw new Error('should not run'); } } }); + testContext.after(() => app.close()); + + const response = await app.inject({ + method: 'GET', + url: '/api/v1/public/search?minPriceMinor=12.5', + }); + + assert.equal(response.statusCode, 400); + assert.equal(response.json().error.code, 'INVALID_SEARCH_FILTER'); }); \ No newline at end of file diff --git a/apps/web/src/composables/useCatalogSearch.js b/apps/web/src/composables/useCatalogSearch.js index 539f5a5..e009c2b 100644 --- a/apps/web/src/composables/useCatalogSearch.js +++ b/apps/web/src/composables/useCatalogSearch.js @@ -2,8 +2,11 @@ import { ref, watch } from 'vue'; import { searchCatalog } from '@/services/catalog-service.js'; -export function useCatalogSearch({ initialQuery = '', initialPage = 1 } = {}) { +export function useCatalogSearch({ initialQuery = '', initialCategory = '', initialMinPriceMinor, initialMaxPriceMinor, initialPage = 1 } = {}) { const query = ref(initialQuery); + const category = ref(initialCategory); + const minPriceMinor = ref(initialMinPriceMinor); + const maxPriceMinor = ref(initialMaxPriceMinor); const page = ref(initialPage); const result = ref({ items: [], pagination: { page: 1, limit: 20, total: 0, totalPages: 0 } }); const isLoading = ref(false); @@ -16,7 +19,7 @@ export function useCatalogSearch({ initialQuery = '', initialPage = 1 } = {}) { error.value = null; try { - const nextResult = await searchCatalog({ query: query.value, page: page.value }); + const nextResult = await searchCatalog({ query: query.value, category: category.value, minPriceMinor: minPriceMinor.value, maxPriceMinor: maxPriceMinor.value, page: page.value }); if (currentRequestId === requestId) result.value = nextResult; } catch (requestError) { if (currentRequestId === requestId) error.value = requestError; @@ -25,7 +28,10 @@ export function useCatalogSearch({ initialQuery = '', initialPage = 1 } = {}) { } } - watch(query, load); + watch([query, category, minPriceMinor, maxPriceMinor], () => { + if (page.value !== 1) page.value = 1; + else load(); + }); watch(page, load); function goToPage(nextPage) { @@ -34,5 +40,5 @@ export function useCatalogSearch({ initialQuery = '', initialPage = 1 } = {}) { } } - return { query, page, result, isLoading, error, load, goToPage }; + return { query, category, minPriceMinor, maxPriceMinor, page, result, isLoading, error, load, goToPage }; } \ No newline at end of file diff --git a/apps/web/src/pages/CatalogPage.vue b/apps/web/src/pages/CatalogPage.vue index 5a07d61..ff85e99 100644 --- a/apps/web/src/pages/CatalogPage.vue +++ b/apps/web/src/pages/CatalogPage.vue @@ -7,17 +7,23 @@ import ListingCard from '@/components/molecules/ListingCard.vue'; const route = useRoute(); const router = useRouter(); -const { query, page, result, isLoading, error, load, goToPage } = useCatalogSearch({ +const { query, category, minPriceMinor, maxPriceMinor, page, result, isLoading, error, load, goToPage } = useCatalogSearch({ initialQuery: typeof route.query.q === 'string' ? route.query.q : '', + initialCategory: typeof route.query.category === 'string' ? route.query.category : '', + initialMinPriceMinor: Number.parseInt(route.query.min, 10) || undefined, + initialMaxPriceMinor: Number.parseInt(route.query.max, 10) || undefined, initialPage: Number.parseInt(route.query.page, 10) || 1, }); onMounted(load); -watch([query, page], ([nextQuery, nextPage]) => { +watch([query, category, minPriceMinor, maxPriceMinor, page], ([nextQuery, nextCategory, nextMin, nextMax, nextPage]) => { router.replace({ query: { ...(nextQuery ? { q: nextQuery } : {}), + ...(nextCategory ? { category: nextCategory } : {}), + ...(typeof nextMin === 'number' ? { min: String(nextMin) } : {}), + ...(typeof nextMax === 'number' ? { max: String(nextMax) } : {}), ...(nextPage > 1 ? { page: String(nextPage) } : {}), }, }); @@ -34,6 +40,11 @@ watch([query, page], ([nextQuery, nextPage]) => { Search the catalog +
+ + + +
diff --git a/apps/web/src/services/catalog-service.js b/apps/web/src/services/catalog-service.js index eb6474f..848bf40 100644 --- a/apps/web/src/services/catalog-service.js +++ b/apps/web/src/services/catalog-service.js @@ -1,7 +1,10 @@ const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1'; -export async function searchCatalog({ query = '', page = 1, limit = 20 } = {}) { +export async function searchCatalog({ query = '', category = '', minPriceMinor, maxPriceMinor, page = 1, limit = 20 } = {}) { const params = new URLSearchParams({ query, page: String(page), limit: String(limit) }); + if (category) params.set('category', category); + if (typeof minPriceMinor === 'number') params.set('minPriceMinor', String(minPriceMinor)); + if (typeof maxPriceMinor === 'number') params.set('maxPriceMinor', String(maxPriceMinor)); const response = await fetch(`${apiBaseUrl}/public/search?${params}`); if (!response.ok) {