open-brainy/tests/unit/utils/paramValidation.test.ts
David Snelling 258e9042af fix(add): empty string is real data, not a missing field
validateAddParams() treated '' as falsy and rejected it with "Missing
required field 'data'" — so a legitimate empty file's first write always
failed. Only null/undefined data (with no vector either) is genuinely
absent; '' is real content. Fixed the check, plus the identical bug in
validateUpdateParams() (truncating a file to empty via overwrite hit the
same falsy check) and in update()/transact()'s update planner, where a
plain `Boolean(params.data)`/truthy check on the resolved vector would have
silently skipped both the deferred-embed marker and the eager re-embed for
an emptied value — a stale vector with no path to ever correct itself.

Verified end-to-end: vfs.writeFile('/empty.txt', '') now succeeds,
readFile() returns '', the file lists, and stat() reports size 0; the
existing "should reject empty string as data" tests (unit + integration)
asserted the old buggy behavior and are updated to assert the fixed
contract instead.
2026-08-25 10:10:19 -07:00

338 lines
No EOL
11 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest'
import {
validateFindParams,
validateAddParams,
validateUpdateParams,
validateRelateParams,
getValidationConfig,
recordQueryPerformance
} from '../../../src/utils/paramValidation.js'
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
import { FindParams, AddParams, UpdateParams, RelateParams } from '../../../src/types/brainy.types.js'
describe('Zero-Config Parameter Validation', () => {
describe('validateFindParams', () => {
it('should accept valid parameters', () => {
expect(() => validateFindParams({
query: 'test query',
limit: 10,
offset: 0
})).not.toThrow()
expect(() => validateFindParams({
type: NounType.Document,
where: { status: 'active' }
})).not.toThrow()
})
it('should reject negative limit', () => {
expect(() => validateFindParams({
limit: -1
})).toThrow('limit must be non-negative')
})
it('should reject negative offset', () => {
expect(() => validateFindParams({
offset: -1
})).toThrow('offset must be non-negative')
})
it('should reject threshold outside 0-1 range', () => {
expect(() => validateFindParams({
near: { id: 'test', threshold: -0.1 }
})).toThrow('threshold must be between 0 and 1')
expect(() => validateFindParams({
near: { id: 'test', threshold: 1.1 }
})).toThrow('threshold must be between 0 and 1')
})
it('should reject both query and vector', () => {
expect(() => validateFindParams({
query: 'test',
vector: new Array(384).fill(0)
})).toThrow('cannot specify both query and vector')
})
it('should refuse cursor outright — even paired with offset — as an unimplemented option', () => {
// cursor is now a typed, unconditional refusal (UnsupportedFindOptionError):
// it used to be accepted-and-ignored, only conflicting when offset was also
// given. Accepted-and-ignored died as a class — cursor refuses on its own,
// so pairing it with offset refuses too, but with the SAME message.
expect(() => validateFindParams({
cursor: 'abc123',
offset: 10
})).toThrow("find() option 'cursor' is not implemented")
})
it('should validate vector dimensions', () => {
expect(() => validateFindParams({
vector: new Array(100).fill(0) // Wrong dimensions
})).toThrow('vector must have exactly 384 dimensions')
expect(() => validateFindParams({
vector: new Array(384).fill(0) // Correct dimensions
})).not.toThrow()
})
it('should validate NounType enum', () => {
expect(() => validateFindParams({
type: 'InvalidType' as any
})).toThrow('invalid NounType: InvalidType')
expect(() => validateFindParams({
type: NounType.Document
})).not.toThrow()
})
it('should validate array of NounTypes', () => {
expect(() => validateFindParams({
type: [NounType.Document, NounType.Person]
})).not.toThrow()
expect(() => validateFindParams({
type: [NounType.Document, 'InvalidType' as any]
})).toThrow('invalid NounType: InvalidType')
})
it('should auto-limit based on system memory (two-tier enforcement)', () => {
const config = getValidationConfig()
// 7.30.2+ design: the cap fires in two tiers.
// - Below cap (`limit <= maxLimit`): silent pass, no signal.
// - Soft tier (`maxLimit < limit <= 2 * maxLimit`): one-time warning
// per call site, query proceeds. No throw.
// - Hard tier (`limit > 2 * maxLimit`): real OOM danger zone, throw.
// Below cap → pass
expect(() => validateFindParams({ limit: config.maxLimit })).not.toThrow()
// Soft tier → no throw (just a warn we don't assert here — proper coverage
// lives in the find-limits integration suite which can intercept the log).
expect(() => validateFindParams({ limit: config.maxLimit + 1 })).not.toThrow()
expect(() => validateFindParams({ limit: config.maxLimit * 2 })).not.toThrow()
// Hard tier → throw with the new message format
expect(() => validateFindParams({
limit: config.maxLimit * 2 + 1
})).toThrow(/exceeds the auto-configured query limit/)
})
it('should auto-limit query length', () => {
const config = getValidationConfig()
const longQuery = 'a'.repeat(config.maxQueryLength + 1)
expect(() => validateFindParams({
query: longQuery
})).toThrow(`query exceeds auto-configured maximum length of ${config.maxQueryLength}`)
})
})
describe('validateAddParams', () => {
it('should accept valid add parameters', () => {
expect(() => validateAddParams({
data: 'test content',
type: NounType.Document
})).not.toThrow()
expect(() => validateAddParams({
vector: new Array(384).fill(0),
type: NounType.Person
})).not.toThrow()
})
it('should require either data or vector', () => {
expect(() => validateAddParams({
type: NounType.Document
} as AddParams)).toThrow('Invalid add() parameters: Missing required field \'data\'')
})
it('should accept an empty string as real data — only null/undefined is "missing"', () => {
// A legitimate empty file's first write: '' is content, not absence.
expect(() => validateAddParams({
data: '',
type: NounType.Document
})).not.toThrow()
// null/undefined (with no vector) is still the genuine missing-field case.
expect(() => validateAddParams({
data: null as any,
type: NounType.Document
})).toThrow('Invalid add() parameters: Missing required field \'data\'')
expect(() => validateAddParams({
data: undefined,
type: NounType.Document
})).toThrow('Invalid add() parameters: Missing required field \'data\'')
})
it('deferEmbedding accepts empty-string data (real content, not absence)', () => {
expect(() => validateAddParams({
data: '',
type: NounType.Document,
deferEmbedding: true
} as AddParams)).not.toThrow()
})
it('should validate NounType', () => {
expect(() => validateAddParams({
data: 'test',
type: 'InvalidType' as any
})).toThrow('Invalid NounType: \'InvalidType\'')
})
it('should validate vector dimensions', () => {
expect(() => validateAddParams({
vector: new Array(100).fill(0),
type: NounType.Document
})).toThrow('vector must have exactly 384 dimensions')
})
})
describe('validateUpdateParams', () => {
it('should accept valid update parameters', () => {
expect(() => validateUpdateParams({
id: 'test-id',
data: 'new content'
})).not.toThrow()
expect(() => validateUpdateParams({
id: 'test-id',
metadata: { status: 'updated' }
})).not.toThrow()
})
it('should require an ID', () => {
expect(() => validateUpdateParams({
data: 'new content'
} as UpdateParams)).toThrow('id is required for update')
})
it('should require at least one field to update', () => {
expect(() => validateUpdateParams({
id: 'test-id'
})).toThrow('must specify at least one field to update')
})
it('empty-string data counts as a real field to update (truncating content)', () => {
expect(() => validateUpdateParams({
id: 'test-id',
data: ''
})).not.toThrow()
})
it('deferEmbedding accepts empty-string data on update', () => {
expect(() => validateUpdateParams({
id: 'test-id',
data: '',
deferEmbedding: true
} as UpdateParams)).not.toThrow()
})
it('should validate NounType if changing', () => {
expect(() => validateUpdateParams({
id: 'test-id',
type: 'InvalidType' as any
})).toThrow('invalid NounType: InvalidType')
expect(() => validateUpdateParams({
id: 'test-id',
type: NounType.Event
})).not.toThrow()
})
})
describe('validateRelateParams', () => {
it('should accept valid relate parameters', () => {
expect(() => validateRelateParams({
from: 'entity1',
to: 'entity2',
type: VerbType.RelatedTo
})).not.toThrow()
expect(() => validateRelateParams({
from: 'entity1',
to: 'entity2',
type: VerbType.Creates,
weight: 0.8
})).not.toThrow()
})
it('should require from and to', () => {
expect(() => validateRelateParams({
to: 'entity2',
type: VerbType.RelatedTo
} as RelateParams)).toThrow('from entity ID is required')
expect(() => validateRelateParams({
from: 'entity1',
type: VerbType.RelatedTo
} as RelateParams)).toThrow('to entity ID is required')
})
// Self-referential relationships are now allowed (valid in graph systems)
// Previous test "should reject self-referential relationships" removed
it('should validate VerbType', () => {
expect(() => validateRelateParams({
from: 'entity1',
to: 'entity2',
type: 'InvalidVerb' as any
})).toThrow('invalid VerbType: InvalidVerb')
})
it('should validate weight range', () => {
expect(() => validateRelateParams({
from: 'entity1',
to: 'entity2',
type: VerbType.RelatedTo,
weight: -0.1
})).toThrow('weight must be between 0 and 1')
expect(() => validateRelateParams({
from: 'entity1',
to: 'entity2',
type: VerbType.RelatedTo,
weight: 1.1
})).toThrow('weight must be between 0 and 1')
})
})
describe('Auto-configuration', () => {
it('should provide configuration based on system resources', () => {
const config = getValidationConfig()
expect(config.maxLimit).toBeGreaterThan(0)
expect(config.maxLimit).toBeLessThanOrEqual(100000)
expect(config.maxQueryLength).toBeGreaterThan(0)
expect(config.maxVectorDimensions).toBe(384)
expect(config.systemMemory).toBeGreaterThan(0)
expect(config.availableMemory).toBeGreaterThan(0)
})
it('never mutates the cap from query timing (telemetry only)', () => {
const initialLimit = getValidationConfig().maxLimit
// Fast queries with large results: no silent growth.
for (let i = 0; i < 10; i++) {
recordQueryPerformance(50, initialLimit * 0.9)
}
expect(getValidationConfig().maxLimit).toBe(initialLimit)
// A burst of catastrophically slow queries must not strangle the cap.
// The removed "learning" ratchet shrank it 20% per recorded query down
// to a floor of 1000 — below the documented MIN_AUTO_QUERY_LIMIT — and
// the error message blamed "available free memory" (a production
// incident: every find({ limit: 5000 }) failed on an idle 23GB-free box).
for (let i = 0; i < 50; i++) {
recordQueryPerformance(90_000, 100)
}
expect(getValidationConfig().maxLimit).toBe(initialLimit)
})
})
})