diff --git a/src/shared/lib/utils/buildQueryString/buildQueryString.test.ts b/src/shared/lib/utils/buildQueryString/buildQueryString.test.ts new file mode 100644 index 0000000..b7e1d7a --- /dev/null +++ b/src/shared/lib/utils/buildQueryString/buildQueryString.test.ts @@ -0,0 +1,194 @@ +/** + * Tests for buildQueryString utility + */ + +import { + describe, + expect, + test, +} from 'vitest'; +import { buildQueryString } from './buildQueryString'; + +describe('buildQueryString', () => { + describe('basic parameter building', () => { + test('should build query string with string parameter', () => { + const result = buildQueryString({ category: 'serif' }); + expect(result).toBe('?category=serif'); + }); + + test('should build query string with number parameter', () => { + const result = buildQueryString({ limit: 50 }); + expect(result).toBe('?limit=50'); + }); + + test('should build query string with boolean parameter', () => { + const result = buildQueryString({ active: true }); + expect(result).toBe('?active=true'); + }); + + test('should build query string with multiple parameters', () => { + const result = buildQueryString({ + category: 'serif', + limit: 50, + page: 1, + }); + expect(result).toBe('?category=serif&limit=50&page=1'); + }); + }); + + describe('array handling', () => { + test('should handle array of strings', () => { + const result = buildQueryString({ + subsets: ['latin', 'latin-ext', 'cyrillic'], + }); + expect(result).toBe('?subsets=latin&subsets=latin-ext&subsets=cyrillic'); + }); + + test('should handle array of numbers', () => { + const result = buildQueryString({ ids: [1, 2, 3] }); + expect(result).toBe('?ids=1&ids=2&ids=3'); + }); + + test('should handle mixed arrays and primitives', () => { + const result = buildQueryString({ + category: 'serif', + subsets: ['latin', 'latin-ext'], + limit: 50, + }); + expect(result).toBe('?category=serif&subsets=latin&subsets=latin-ext&limit=50'); + }); + + test('should filter out null/undefined values in arrays', () => { + const result = buildQueryString({ + // @ts-expect-error - Testing runtime behavior with invalid types + ids: [1, null, 3, undefined], + }); + expect(result).toBe('?ids=1&ids=3'); + }); + }); + + describe('optional values', () => { + test('should exclude undefined values', () => { + const result = buildQueryString({ + category: 'serif', + search: undefined, + }); + expect(result).toBe('?category=serif'); + }); + + test('should exclude null values', () => { + const result = buildQueryString({ + category: 'serif', + search: null, + }); + expect(result).toBe('?category=serif'); + }); + + test('should handle all undefined/null values', () => { + const result = buildQueryString({ + category: undefined, + search: null, + }); + expect(result).toBe(''); + }); + }); + + describe('URL encoding', () => { + test('should encode spaces', () => { + const result = buildQueryString({ search: 'hello world' }); + expect(result).toBe('?search=hello+world'); + }); + + test('should encode special characters', () => { + const result = buildQueryString({ query: 'a&b=c+d' }); + expect(result).toBe('?query=a%26b%3Dc%2Bd'); + }); + + test('should encode Unicode characters', () => { + const result = buildQueryString({ text: 'café' }); + expect(result).toBe('?text=caf%C3%A9'); + }); + + test('should encode reserved URL characters', () => { + const result = buildQueryString({ url: 'https://example.com' }); + expect(result).toBe('?url=https%3A%2F%2Fexample.com'); + }); + }); + + describe('edge cases', () => { + test('should return empty string for empty object', () => { + const result = buildQueryString({}); + expect(result).toBe(''); + }); + + test('should return empty string when all values are excluded', () => { + const result = buildQueryString({ + a: undefined, + b: null, + }); + expect(result).toBe(''); + }); + + test('should handle empty arrays', () => { + const result = buildQueryString({ tags: [] }); + expect(result).toBe(''); + }); + + test('should handle zero values', () => { + const result = buildQueryString({ page: 0, count: 0 }); + expect(result).toBe('?page=0&count=0'); + }); + + test('should handle false boolean', () => { + const result = buildQueryString({ active: false }); + expect(result).toBe('?active=false'); + }); + + test('should handle empty string', () => { + const result = buildQueryString({ search: '' }); + expect(result).toBe('?search='); + }); + }); + + describe('parameter order', () => { + test('should maintain parameter order from input object', () => { + const result = buildQueryString({ + a: '1', + b: '2', + c: '3', + }); + expect(result).toBe('?a=1&b=2&c=3'); + }); + }); + + describe('real-world examples', () => { + test('should handle Google Fonts API parameters', () => { + const result = buildQueryString({ + category: 'sans-serif', + sort: 'popularity', + subset: 'latin', + }); + expect(result).toBe('?category=sans-serif&sort=popularity&subset=latin'); + }); + + test('should handle Fontshare API parameters', () => { + const result = buildQueryString({ + categories: ['Sans', 'Serif'], + page: 1, + limit: 50, + search: 'satoshi', + }); + expect(result).toBe('?categories=Sans&categories=Serif&page=1&limit=50&search=satoshi'); + }); + + test('should handle pagination parameters', () => { + const result = buildQueryString({ + page: 2, + per_page: 20, + sort: 'name', + order: 'desc', + }); + expect(result).toBe('?page=2&per_page=20&sort=name&order=desc'); + }); + }); +}); diff --git a/src/shared/lib/utils/buildQueryString/buildQueryString.ts b/src/shared/lib/utils/buildQueryString/buildQueryString.ts new file mode 100644 index 0000000..fc09249 --- /dev/null +++ b/src/shared/lib/utils/buildQueryString/buildQueryString.ts @@ -0,0 +1,79 @@ +/** + * Build query string from URL parameters + * + * Generic, type-safe function to build properly encoded query strings + * from URL parameters. Supports primitives, arrays, and optional values. + * + * @param params - Object containing query parameters + * @returns Encoded query string (empty string if no parameters) + * + * @example + * ```ts + * buildQueryString({ category: 'serif', subsets: ['latin', 'latin-ext'] }) + * // Returns: "category=serif&subsets=latin&subsets=latin-ext" + * + * buildQueryString({ limit: 50, page: 1 }) + * // Returns: "limit=50&page=1" + * + * buildQueryString({}) + * // Returns: "" + * + * buildQueryString({ search: 'hello world', active: true }) + * // Returns: "search=hello%20world&active=true" + * ``` + */ + +/** + * Query parameter value type + * Supports primitives, arrays, and excludes null/undefined + */ +export type QueryParamValue = string | number | boolean | string[] | number[]; + +/** + * Query parameters object + */ +export type QueryParams = Record; + +/** + * Build query string from URL parameters + * + * Handles: + * - Primitive values (string, number, boolean) + * - Arrays (multiple values with same key) + * - Optional values (excludes undefined/null) + * - Proper URL encoding + * + * Edge cases: + * - Empty object → empty string + * - No parameters → empty string + * - Nested objects → flattens to string representation + * - Special characters → proper encoding + * + * @param params - Object containing query parameters + * @returns Encoded query string (with "?" prefix if non-empty) + */ +export function buildQueryString(params: QueryParams): string { + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(params)) { + // Skip undefined/null values + if (value === undefined || value === null) { + continue; + } + + // Handle arrays (multiple values with same key) + if (Array.isArray(value)) { + for (const item of value) { + if (item !== undefined && item !== null) { + searchParams.append(key, String(item)); + } + } + } else { + // Handle primitives + searchParams.append(key, String(value)); + } + } + + const queryString = searchParams.toString(); + return queryString ? `?${queryString}` : ''; +}