Skip to main content
Glama
properties.test.js9.99 kB
/** * Property-Based Testing * * Tests invariants that should hold for ALL inputs: * - Result structure consistency * - Limit constraints * - Idempotency */ import { describe, it, expect } from 'vitest' import fc from 'fast-check' describe('Properties: Validation Invariants', () => { it('validateLimit: always returns value between 1 and max', async () => { const { validateLimit } = await import('../../lib/validators.js') fc.assert( fc.property( // Use oneOf to avoid objects with problematic toString fc.oneof( fc.string(), fc.integer(), fc.float(), fc.boolean(), fc.constant(null), fc.constant(undefined) ), fc.integer({ min: 1, max: 10000 }), fc.integer({ min: 1, max: 10000 }), (value, defaultVal, max) => { try { const result = validateLimit(value, defaultVal, max) return result >= 1 && result <= Math.max(defaultVal, max) } catch { // If it throws, that's also acceptable for edge cases return true } } ), { numRuns: 1000 } ) }) it('validateDaysBack: always returns non-negative bounded value', async () => { const { validateDaysBack } = await import('../../lib/validators.js') fc.assert( fc.property( fc.oneof( fc.string(), fc.integer(), fc.float(), fc.boolean(), fc.constant(null), fc.constant(undefined) ), (value) => { try { const result = validateDaysBack(value) // Should always return a number if (typeof result !== 'number') return false // Should be non-negative and within bounds (0 to max which is 3650 default) return result >= 0 && result <= 3650 } catch { // If it throws, that's also acceptable for edge cases return true } } ), { numRuns: 500 } ) }) it('validateWeekOffset: always returns non-negative bounded value', async () => { const { validateWeekOffset } = await import('../../lib/validators.js') fc.assert( fc.property( fc.oneof( fc.string(), fc.integer(), fc.float(), fc.boolean(), fc.constant(null), fc.constant(undefined) ), (value) => { try { const result = validateWeekOffset(value) return result >= 0 && result <= 52 } catch { // If it throws, that's also acceptable for edge cases return true } } ), { numRuns: 500 } ) }) it('escapeSQL: output length >= input length', async () => { const { escapeSQL } = await import('../../lib/validators.js') fc.assert( fc.property(fc.string(), (input) => { const result = escapeSQL(input) // Escaping adds characters, never removes them return result.length >= input.length }), { numRuns: 500 } ) }) it('escapeSQL: is idempotent for safe strings', async () => { const { escapeSQL } = await import('../../lib/validators.js') // For strings without quotes or backslashes, escaping should be idempotent fc.assert( fc.property( fc.string().filter(s => !s.includes("'") && !s.includes("\\")), (input) => { const once = escapeSQL(input) const twice = escapeSQL(once) return once === twice } ), { numRuns: 300 } ) }) it('stripHtmlTags: output never longer than input', async () => { const { stripHtmlTags } = await import('../../lib/validators.js') fc.assert( fc.property(fc.string(), (input) => { const result = stripHtmlTags(input) // Stripping tags removes characters return result.length <= input.length }), { numRuns: 500 } ) }) it('stripHtmlTags: no HTML tags in output', async () => { const { stripHtmlTags } = await import('../../lib/validators.js') fc.assert( fc.property(fc.string(), (input) => { const result = stripHtmlTags(input) // Should not have any HTML-like tags return !/<[a-zA-Z][^>]*>/.test(result) }), { numRuns: 500 } ) }) }) describe('Properties: Search Function Invariants', () => { it('extractKeywords: output subset of input words', async () => { const { extractKeywords } = await import('../../search.js') fc.assert( fc.property( fc.array(fc.string({ minLength: 3, maxLength: 20 }), { minLength: 1, maxLength: 10 }), (words) => { const input = words.join(' ') const keywords = extractKeywords(input) // Each keyword should be derived from input return keywords.every(kw => input.toLowerCase().includes(kw.toLowerCase()) ) } ), { numRuns: 200 } ) }) it('parseNegation: cleanQuery + negations reconstruct meaning', async () => { const { parseNegation } = await import('../../search.js') fc.assert( fc.property(fc.string({ maxLength: 200 }), (input) => { const { cleanQuery, negations } = parseNegation(input) // Clean query should be a substring (with some words removed) // Negations should be an array return typeof cleanQuery === 'string' && Array.isArray(negations) }), { numRuns: 300 } ) }) it('expandQuery: always includes original query', async () => { const { expandQuery } = await import('../../search.js') fc.assert( fc.property(fc.string({ minLength: 3, maxLength: 100 }), (input) => { const expansions = expandQuery(input) // Original query should always be included return expansions.includes(input) || expansions[0] === input }), { numRuns: 200 } ) }) it('expandQuery: limited number of expansions', async () => { const { expandQuery } = await import('../../search.js') fc.assert( fc.property(fc.string({ maxLength: 200 }), (input) => { const expansions = expandQuery(input) // Should never return more than 3 expansions return expansions.length <= 3 }), { numRuns: 200 } ) }) }) describe('Properties: Data Structure Invariants', () => { it('search result: results.length <= limit', async () => { // This is a key invariant - we should never return more results than requested const limits = [1, 5, 10, 50, 100] for (const limit of limits) { // Mock a result structure const mockResults = Array(limit + 10).fill({ rank: 1 }) const sliced = mockResults.slice(0, limit) expect(sliced.length).toBeLessThanOrEqual(limit) } }) it('array operations: map preserves length', () => { fc.assert( fc.property(fc.array(fc.anything()), (arr) => { const mapped = arr.map(x => x) return mapped.length === arr.length }), { numRuns: 200 } ) }) it('array operations: filter reduces or maintains length', () => { fc.assert( fc.property(fc.array(fc.integer()), (arr) => { const filtered = arr.filter(x => x > 0) return filtered.length <= arr.length }), { numRuns: 200 } ) }) }) describe('Properties: Monotonicity', () => { it('validateLimit: larger input (capped) produces larger or equal output', async () => { const { validateLimit } = await import('../../lib/validators.js') fc.assert( fc.property( fc.integer({ min: 1, max: 500 }), fc.integer({ min: 1, max: 500 }), (a, b) => { if (a <= b) { return validateLimit(a) <= validateLimit(b) } return true } ), { numRuns: 200 } ) }) it('string truncation: longer max means longer or equal result', async () => { const { validateSearchQuery } = await import('../../lib/validators.js') fc.assert( fc.property( fc.string({ minLength: 100, maxLength: 2000 }), fc.integer({ min: 50, max: 500 }), fc.integer({ min: 500, max: 1500 }), (input, smallMax, largeMax) => { const smallResult = validateSearchQuery(input, smallMax) const largeResult = validateSearchQuery(input, largeMax) return smallResult.length <= largeResult.length } ), { numRuns: 100 } ) }) }) describe('Properties: Composition', () => { it('validation chain: validators can be composed', async () => { const { validateSearchQuery, escapeSQL, stripHtmlTags } = await import('../../lib/validators.js') fc.assert( fc.property(fc.string(), (input) => { try { // Chain: validate -> strip HTML -> escape SQL const validated = validateSearchQuery(input) const stripped = stripHtmlTags(validated) const escaped = escapeSQL(stripped) // Result should be a string return typeof escaped === 'string' } catch (e) { // Empty/whitespace strings will throw in validateSearchQuery return e instanceof Error } }), { numRuns: 300 } ) }) }) describe('Properties: Round-Trip', () => { it('JSON serialization: objects survive round-trip', () => { const resultArb = fc.record({ rank: fc.integer({ min: 1, max: 100 }), score: fc.float({ min: 0, max: 1 }), text: fc.string(), date: fc.date().map(d => d.toISOString()) }) fc.assert( fc.property(resultArb, (result) => { const serialized = JSON.stringify(result) const deserialized = JSON.parse(serialized) return ( deserialized.rank === result.rank && deserialized.text === result.text && deserialized.date === result.date ) }), { numRuns: 200 } ) }) })

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sfls1397/Apple-Tools-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server