diff --git a/apps/api/src/modules/search/domain/search-query.js b/apps/api/src/modules/search/domain/search-query.js new file mode 100644 index 0000000..f71cdb0 --- /dev/null +++ b/apps/api/src/modules/search/domain/search-query.js @@ -0,0 +1,70 @@ +function requiredString(value, fieldName) { + if (typeof value !== 'string') { + throw new Error(`${fieldName} must be a string.`); + } + + return value; +} + +export function normalizeSearchQuery(value) { + return requiredString(value, 'Search query') + .trim() + .normalize('NFKC') + .replace(/[^\p{L}\p{N}\s]+/gu, ' ') + .replace(/\s+/g, ' ') + .toLowerCase(); +} + +export function createSearchQuery({ + query = '', + category, + minPriceMinor, + maxPriceMinor, + page = 1, + limit = 20, + includeSold = false, +}) { + const text = normalizeSearchQuery(query); + + if (!Number.isInteger(page) || page < 1) { + throw new Error('page must be >= 1'); + } + + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new Error('limit must be between 1 and 100'); + } + + if (typeof minPriceMinor !== 'undefined' && (!Number.isInteger(minPriceMinor) || minPriceMinor < 0)) { + throw new Error('minPriceMinor must be a non-negative integer'); + } + + if (typeof maxPriceMinor !== 'undefined' && (!Number.isInteger(maxPriceMinor) || maxPriceMinor < 0)) { + throw new Error('maxPriceMinor must be a non-negative integer'); + } + + if ( + typeof minPriceMinor !== 'undefined' && + typeof maxPriceMinor !== 'undefined' && + minPriceMinor > maxPriceMinor + ) { + throw new Error('minPriceMinor must be <= maxPriceMinor'); + } + + return { + text, + ...(typeof category === 'string' && category.trim().length > 0 ? { category: category.trim() } : {}), + ...(typeof minPriceMinor === 'number' ? { minPriceMinor } : {}), + ...(typeof maxPriceMinor === 'number' ? { maxPriceMinor } : {}), + page, + limit, + includeSold, + }; +} + +export function buildSearchFilters({ includeSold = false, status = 'AVAILABLE', sort = ['recently_added'] }) { + return { + status: includeSold ? 'ALL' : status, + includeSold, + sort, + }; +} diff --git a/apps/api/tests/domain/search-query.test.js b/apps/api/tests/domain/search-query.test.js new file mode 100644 index 0000000..7fc06ba --- /dev/null +++ b/apps/api/tests/domain/search-query.test.js @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildSearchFilters, + createSearchQuery, + normalizeSearchQuery, +} from '../../src/modules/search/domain/search-query.js'; + +test('normalizes a public search query and keeps filter values within bounds', () => { + const query = createSearchQuery({ + query: ' Charizard ex ', + category: 'Cards', + minPriceMinor: 1000, + maxPriceMinor: 5000, + page: 1, + limit: 20, + includeSold: false, + }); + + assert.equal(query.text, 'charizard ex'); + assert.equal(query.category, 'Cards'); + assert.equal(query.minPriceMinor, 1000); + assert.equal(query.maxPriceMinor, 5000); + assert.equal(query.page, 1); + assert.equal(query.limit, 20); + assert.equal(query.includeSold, false); +}); + +test('rejects invalid pagination and price boundaries', () => { + assert.throws(() => createSearchQuery({ query: 'abc', page: 0, limit: 20 }), /page must be >= 1/); + assert.throws(() => createSearchQuery({ query: 'abc', minPriceMinor: 5000, maxPriceMinor: 1000 }), /minPriceMinor must be <= maxPriceMinor/); + assert.throws(() => createSearchQuery({ query: 'abc', limit: 0 }), /limit must be between 1 and 100/); +}); + +test('builds a public query filter set that hides sold items by default', () => { + const filters = buildSearchFilters({ includeSold: false }); + + assert.equal(filters.status, 'AVAILABLE'); + assert.equal(filters.includeSold, false); + assert.deepEqual(filters.sort, ['recently_added']); +}); + +test('normalizes whitespace and punctuation in free-text search terms', () => { + assert.equal(normalizeSearchQuery(' Pikachu #199 / holo '), 'pikachu 199 holo'); +});