/**
* 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 }
)
})
})