diff --git a/src/modules/creators/creators.boolean-query.parse.test.ts b/src/modules/creators/creators.boolean-query.parse.test.ts new file mode 100644 index 0000000..51be843 --- /dev/null +++ b/src/modules/creators/creators.boolean-query.parse.test.ts @@ -0,0 +1,36 @@ +import { strict as assert } from 'assert'; +import { CreatorListQuerySchema } from './creators.schemas'; +import { parsePublicQuery } from '../../utils/public-query-parse.utils'; + +function run() { + const trueParsed = CreatorListQuerySchema.parse({ + verified: 'true', + }); + assert.equal(trueParsed.verified, true); + + const falseParsed = CreatorListQuerySchema.parse({ + verified: '0', + }); + assert.equal(falseParsed.verified, false); + + const invalid = parsePublicQuery(CreatorListQuerySchema, { + verified: 'yes', + }); + + assert.equal(invalid.ok, false); + if (invalid.ok) { + throw new Error('Expected invalid boolean query flag to fail validation'); + } + + assert.deepEqual(invalid.details, [ + { + field: 'verified', + message: + 'Invalid boolean value for query parameter "verified": received "yes". Accepted values: "true", "false", "1", "0".', + }, + ]); + + console.log('creators.boolean-query.parse tests passed'); +} + +run(); diff --git a/src/modules/creators/creators.filter.test.ts b/src/modules/creators/creators.filter.test.ts index 40112a8..5f9909f 100644 --- a/src/modules/creators/creators.filter.test.ts +++ b/src/modules/creators/creators.filter.test.ts @@ -34,7 +34,15 @@ function run() { assert.deepEqual(parseCreatorFilters({ verified: 'true' }), { verified: true }); assert.deepEqual(parseCreatorFilters({ verified: 'false' }), { verified: false }); + assert.deepEqual(parseCreatorFilters({ verified: '1' }), { verified: true }); + assert.deepEqual(parseCreatorFilters({ verified: '0' }), { verified: false }); assert.deepEqual(parseCreatorFilters({ verified: true }), { verified: true }); + assert.deepEqual(parseCreatorFilters({ verified: false }), { verified: false }); + + assert.throws( + () => parseCreatorFilters({ verified: 'yes' }), + /Accepted values: "true", "false", "1", "0"\./ + ); // --- combined --- diff --git a/src/modules/creators/creators.filter.ts b/src/modules/creators/creators.filter.ts index 89b377c..fdf7e27 100644 --- a/src/modules/creators/creators.filter.ts +++ b/src/modules/creators/creators.filter.ts @@ -2,6 +2,7 @@ // Parser for creator list filter input. Reusable across list handlers. import { rejectUnknownKeys } from '../../utils/filter-whitelist.utils'; +import { parseBoolean } from '../../utils/parseBoolean.utils'; /** * Supported filter keys for creator list requests. @@ -22,7 +23,7 @@ export interface CreatorFilterInput { * Parse and validate raw query filter input for creator list requests. * * - Accepts only supported filter keys; rejects unknown ones with an error - * - Coerces `verified` string to boolean + * - Parses `verified` using the shared boolean query flag helper * - Trims `search` string * - Repeated calls with the same input return the same result * @@ -46,7 +47,14 @@ export function parseCreatorFilters( const result: CreatorFilterInput = {}; if (raw.verified !== undefined) { - result.verified = raw.verified === 'true' || raw.verified === true; + const verified = parseBoolean( + 'verified', + raw.verified as string | string[] | boolean | null | undefined + ); + + if (verified !== null) { + result.verified = verified; + } } if (typeof raw.search === 'string') { diff --git a/src/modules/creators/creators.schemas.ts b/src/modules/creators/creators.schemas.ts index 364194f..a4897a4 100644 --- a/src/modules/creators/creators.schemas.ts +++ b/src/modules/creators/creators.schemas.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { creatorListSortDirectionQueryParam } from './creators.sort-direction.parse'; import { creatorListIncludeQueryParam } from './creators.include.parse'; import { withCreatorListQueryStringNormalization } from './creators.query-string.utils'; -import { safeIntParam } from '../../utils/query.utils'; +import { safeBooleanQueryParam, safeIntParam } from '../../utils/query.utils'; import { MIN_PAGE_SIZE, MAX_PAGE_SIZE, @@ -57,14 +57,9 @@ export const CreatorListQuerySchema = z include: creatorListIncludeQueryParam(), // Filters - verified: withCreatorListQueryStringNormalization( - z - .string() - .optional() - .transform((val: string | undefined) => - val === undefined ? undefined : val === 'true' - ) - ), + verified: safeBooleanQueryParam({ + paramName: 'verified', + }), search: withCreatorListQueryStringNormalization( z @@ -101,4 +96,4 @@ export const UpdateCreatorProfileSchema = z.object({ perks: z.array(CreatorPerkSchema).optional(), }).strict(); -export type UpdateCreatorProfileType = z.infer; \ No newline at end of file +export type UpdateCreatorProfileType = z.infer; diff --git a/src/utils/parseBoolean.utils.ts b/src/utils/parseBoolean.utils.ts index 8c45929..d921722 100644 --- a/src/utils/parseBoolean.utils.ts +++ b/src/utils/parseBoolean.utils.ts @@ -1,102 +1,80 @@ /** - * parseBoolean.utils.ts + * Reusable parser for boolean-like query flags. * - * Reusable parser for boolean query parameters across creator endpoints. + * Query strings arrive as strings, so this utility normalizes accepted flag + * forms into a real boolean and rejects everything else. * - * HTTP query strings are always strings, so a value of `true` arrives as - * the literal string "true". This utility normalises common truthy/falsy - * variants into a proper boolean (or null when the value is absent) and - * rejects anything that cannot be unambiguously interpreted. - * - * Supported true variants : "true" | "1" | "yes" | "on" - * Supported false variants : "false" | "0" | "no" | "off" - * Absent value (undefined) : returns null — caller decides the default - * Any other string : throws ParseBooleanError (400-safe) + * Supported true variants: "true" | "1" + * Supported false variants: "false" | "0" + * Absent value (undefined/null): returns null + * Any other value: throws ParseBooleanError */ -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const TRUE_VALUES = new Set(["true", "1", "yes", "on"]); -const FALSE_VALUES = new Set(["false", "0", "no", "off"]); - -// --------------------------------------------------------------------------- -// Error type -// --------------------------------------------------------------------------- +const TRUE_VALUES = new Set(['true', '1']); +const FALSE_VALUES = new Set(['false', '0']); export class ParseBooleanError extends Error { - /** The raw value that could not be parsed */ public readonly rawValue: string; - /** The query parameter name, for clearer error messages */ public readonly paramName: string; constructor(paramName: string, rawValue: string) { super( `Invalid boolean value for query parameter "${paramName}": received "${rawValue}". ` + - `Accepted values: "true", "false", "1", "0", "yes", "no", "on", "off".` + `Accepted values: "true", "false", "1", "0".` ); - this.name = "ParseBooleanError"; + this.name = 'ParseBooleanError'; this.rawValue = rawValue; this.paramName = paramName; } } -// --------------------------------------------------------------------------- -// Core parser -// --------------------------------------------------------------------------- - /** * Parses a raw query string value into a boolean. * * @param paramName - Name of the query parameter (used in error messages) - * @param raw - The raw value from `req.query[paramName]` + * @param raw - The raw value from `req.query[paramName]` * @returns `true`, `false`, or `null` when the parameter is absent - * @throws {ParseBooleanError} when the value is present but unrecognised - * - * @example - * // ?isVerified=true → true - * // ?isVerified=0 → false - * // ?isVerified=yes → true - * // ?isVerified= → throws ParseBooleanError - * // (param absent) → null + * @throws {ParseBooleanError} when the value is present but unrecognized */ export function parseBoolean( paramName: string, - raw: string | string[] | undefined + raw: string | string[] | boolean | null | undefined ): boolean | null { - // Absent parameter — caller applies its own default - if (raw === undefined) return null; + if (raw === undefined || raw === null) { + return null; + } - // Always work with a single string; ignore arrays (take the first value) - const value = (Array.isArray(raw) ? raw[0] : raw).trim().toLowerCase(); + if (typeof raw === 'boolean') { + return raw; + } - if (TRUE_VALUES.has(value)) return true; - if (FALSE_VALUES.has(value)) return false; + const rawValue = Array.isArray(raw) ? raw[0] : raw; + if (typeof rawValue !== 'string') { + throw new ParseBooleanError(paramName, String(rawValue)); + } - throw new ParseBooleanError(paramName, Array.isArray(raw) ? raw[0] : raw); -} + const value = rawValue.trim().toLowerCase(); -// --------------------------------------------------------------------------- -// Convenience wrapper with a fallback default -// --------------------------------------------------------------------------- + if (TRUE_VALUES.has(value)) { + return true; + } + + if (FALSE_VALUES.has(value)) { + return false; + } + + throw new ParseBooleanError(paramName, rawValue); +} /** * Same as `parseBoolean` but returns a caller-supplied default when the * parameter is absent instead of null. - * - * @param paramName - Name of the query parameter - * @param raw - The raw value from `req.query[paramName]` - * @param defaultValue - Value to return when the parameter is absent - * - * @example - * const isVerified = parseBooleanWithDefault("isVerified", req.query.isVerified, false); */ export function parseBooleanWithDefault( paramName: string, - raw: string | string[] | undefined, + raw: string | string[] | boolean | null | undefined, defaultValue: boolean ): boolean { const result = parseBoolean(paramName, raw); return result === null ? defaultValue : result; -} \ No newline at end of file +} diff --git a/src/utils/query.utils.ts b/src/utils/query.utils.ts index 9987e77..c728da8 100644 --- a/src/utils/query.utils.ts +++ b/src/utils/query.utils.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { parseBoolean, ParseBooleanError } from './parseBoolean.utils'; /** * Creates a Zod schema for safely parsing an integer query parameter. @@ -24,3 +25,33 @@ export function safeIntParam(options: { message: `${label} must be an integer between ${min} and ${max}`, }); } + +/** + * Creates a Zod schema for safely parsing a boolean-like query parameter. + * + * Accepts the common string flag forms supported by `parseBoolean` and maps + * invalid values into a stable validation error message. + */ +export function safeBooleanQueryParam(options: { + paramName: string; + defaultValue?: boolean; +}) { + const { paramName, defaultValue } = options; + + return z.any().optional().transform((raw, ctx) => { + try { + const parsed = parseBoolean(paramName, raw); + return parsed === null ? defaultValue : parsed; + } catch (error) { + if (error instanceof ParseBooleanError) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: error.message, + }); + return z.NEVER; + } + + throw error; + } + }); +} diff --git a/src/utils/test/parseBoolean.utils.test.ts b/src/utils/test/parseBoolean.utils.test.ts index a756b05..fe8e2c8 100644 --- a/src/utils/test/parseBoolean.utils.test.ts +++ b/src/utils/test/parseBoolean.utils.test.ts @@ -2,176 +2,151 @@ import { parseBoolean, parseBooleanWithDefault, ParseBooleanError, -} from "../parseBoolean.utils"; +} from '../parseBoolean.utils'; -// --------------------------------------------------------------------------- -// parseBoolean() -// --------------------------------------------------------------------------- - -describe("parseBoolean()", () => { - - // ── True variants ──────────────────────────────────────────────────────── - - describe("true variants", () => { - const trueInputs = ["true", "1", "yes", "on"]; +describe('parseBoolean()', () => { + describe('true variants', () => { + const trueInputs = ['true', '1']; trueInputs.forEach((input) => { it(`returns true for "${input}"`, () => { - expect(parseBoolean("isVerified", input)).toBe(true); + expect(parseBoolean('isVerified', input)).toBe(true); }); it(`returns true for uppercase "${input.toUpperCase()}"`, () => { - expect(parseBoolean("isVerified", input.toUpperCase())).toBe(true); - }); - - it(`returns true for mixed-case "${input[0].toUpperCase() + input.slice(1)}"`, () => { - expect( - parseBoolean("isVerified", input[0].toUpperCase() + input.slice(1)) - ).toBe(true); + expect(parseBoolean('isVerified', input.toUpperCase())).toBe(true); }); }); }); - // ── False variants ─────────────────────────────────────────────────────── - - describe("false variants", () => { - const falseInputs = ["false", "0", "no", "off"]; + describe('false variants', () => { + const falseInputs = ['false', '0']; falseInputs.forEach((input) => { it(`returns false for "${input}"`, () => { - expect(parseBoolean("isVerified", input)).toBe(false); + expect(parseBoolean('isVerified', input)).toBe(false); }); it(`returns false for uppercase "${input.toUpperCase()}"`, () => { - expect(parseBoolean("isVerified", input.toUpperCase())).toBe(false); + expect(parseBoolean('isVerified', input.toUpperCase())).toBe(false); }); }); }); - // ── Absent parameter ───────────────────────────────────────────────────── + describe('absent parameter', () => { + it('returns null when value is undefined', () => { + expect(parseBoolean('isVerified', undefined)).toBeNull(); + }); - describe("absent parameter", () => { - it("returns null when value is undefined", () => { - expect(parseBoolean("isVerified", undefined)).toBeNull(); + it('returns null when value is null', () => { + expect(parseBoolean('isVerified', null)).toBeNull(); }); }); - // ── Whitespace handling ────────────────────────────────────────────────── - - describe("whitespace trimming", () => { - it("trims leading/trailing whitespace before parsing", () => { - expect(parseBoolean("isVerified", " true ")).toBe(true); - expect(parseBoolean("isVerified", " false ")).toBe(false); + describe('non-string boolean input', () => { + it('passes through boolean values', () => { + expect(parseBoolean('isVerified', true)).toBe(true); + expect(parseBoolean('isVerified', false)).toBe(false); }); }); - // ── Array input (req.query returns string[] sometimes) ────────────────── - - describe("array input", () => { - it("uses the first element of an array", () => { - expect(parseBoolean("isVerified", ["true", "false"])).toBe(true); - expect(parseBoolean("isVerified", ["false", "true"])).toBe(false); + describe('whitespace trimming', () => { + it('trims leading/trailing whitespace before parsing', () => { + expect(parseBoolean('isVerified', ' true ')).toBe(true); + expect(parseBoolean('isVerified', ' false ')).toBe(false); }); }); - // ── Invalid values ─────────────────────────────────────────────────────── + describe('array input', () => { + it('uses the first element of an array', () => { + expect(parseBoolean('isVerified', ['true', 'false'])).toBe(true); + expect(parseBoolean('isVerified', ['false', 'true'])).toBe(false); + }); + }); - describe("invalid values", () => { + describe('invalid values', () => { const invalidInputs = [ - "maybe", - "2", - "-1", - "t", - "f", - "y", - "n", - "enabled", - "disabled", - "TRUE_VAL", - "", // empty string after trim is still invalid - " ", // whitespace-only + 'maybe', + '2', + '-1', + 'yes', + 'no', + 'on', + 'off', + 't', + 'f', + '', + ' ', ]; invalidInputs.forEach((input) => { it(`throws ParseBooleanError for "${input}"`, () => { - expect(() => parseBoolean("isVerified", input)).toThrow( + expect(() => parseBoolean('isVerified', input)).toThrow( ParseBooleanError ); }); }); - it("includes the param name in the error message", () => { - expect(() => parseBoolean("isActive", "maybe")).toThrow( - /isActive/ - ); + it('includes the param name in the error message', () => { + expect(() => parseBoolean('isActive', 'maybe')).toThrow(/isActive/); }); - it("includes the raw value in the error message", () => { - expect(() => parseBoolean("isActive", "maybe")).toThrow( - /maybe/ - ); + it('includes the raw value in the error message', () => { + expect(() => parseBoolean('isActive', 'maybe')).toThrow(/maybe/); }); - it("includes accepted values hint in the error message", () => { - expect(() => parseBoolean("isActive", "bad")).toThrow( - /Accepted values/ + it('includes accepted values hint in the error message', () => { + expect(() => parseBoolean('isActive', 'bad')).toThrow( + /Accepted values: "true", "false", "1", "0"\./ ); }); - it("sets rawValue on the error instance", () => { + it('sets rawValue on the error instance', () => { try { - parseBoolean("isActive", "bad"); + parseBoolean('isActive', 'bad'); } catch (e) { expect(e).toBeInstanceOf(ParseBooleanError); - expect((e as ParseBooleanError).rawValue).toBe("bad"); + expect((e as ParseBooleanError).rawValue).toBe('bad'); } }); - it("sets paramName on the error instance", () => { + it('sets paramName on the error instance', () => { try { - parseBoolean("isActive", "bad"); + parseBoolean('isActive', 'bad'); } catch (e) { expect(e).toBeInstanceOf(ParseBooleanError); - expect((e as ParseBooleanError).paramName).toBe("isActive"); + expect((e as ParseBooleanError).paramName).toBe('isActive'); } }); }); }); -// --------------------------------------------------------------------------- -// parseBooleanWithDefault() -// --------------------------------------------------------------------------- - -describe("parseBooleanWithDefault()", () => { - it("returns the parsed value when present", () => { - expect(parseBooleanWithDefault("isVerified", "true", false)).toBe(true); - expect(parseBooleanWithDefault("isVerified", "false", true)).toBe(false); +describe('parseBooleanWithDefault()', () => { + it('returns the parsed value when present', () => { + expect(parseBooleanWithDefault('isVerified', 'true', false)).toBe(true); + expect(parseBooleanWithDefault('isVerified', 'false', true)).toBe(false); }); - it("returns the default when param is undefined", () => { - expect(parseBooleanWithDefault("isVerified", undefined, false)).toBe(false); - expect(parseBooleanWithDefault("isVerified", undefined, true)).toBe(true); + it('returns the default when param is undefined', () => { + expect(parseBooleanWithDefault('isVerified', undefined, false)).toBe(false); + expect(parseBooleanWithDefault('isVerified', undefined, true)).toBe(true); }); - it("still throws ParseBooleanError for invalid values", () => { + it('still throws ParseBooleanError for invalid values', () => { expect(() => - parseBooleanWithDefault("isVerified", "maybe", false) + parseBooleanWithDefault('isVerified', 'maybe', false) ).toThrow(ParseBooleanError); }); }); -// --------------------------------------------------------------------------- -// ParseBooleanError -// --------------------------------------------------------------------------- - -describe("ParseBooleanError", () => { - it("is an instance of Error", () => { - const err = new ParseBooleanError("field", "bad"); +describe('ParseBooleanError', () => { + it('is an instance of Error', () => { + const err = new ParseBooleanError('field', 'bad'); expect(err).toBeInstanceOf(Error); }); - it("has name ParseBooleanError", () => { - const err = new ParseBooleanError("field", "bad"); - expect(err.name).toBe("ParseBooleanError"); + it('has name ParseBooleanError', () => { + const err = new ParseBooleanError('field', 'bad'); + expect(err.name).toBe('ParseBooleanError'); }); -}); \ No newline at end of file +});