428 lines
11 KiB
TypeScript
428 lines
11 KiB
TypeScript
|
|
/**
|
||
|
|
* Integration Hub - Core Tests
|
||
|
|
*
|
||
|
|
* Tests for EventBus, TabularExporter, and core functionality.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||
|
|
import { EventBus } from '../../src/integrations/core/EventBus.js'
|
||
|
|
import { TabularExporter } from '../../src/integrations/core/TabularExporter.js'
|
||
|
|
import { NounType, VerbType } from '../../src/types/graphTypes.js'
|
||
|
|
import type { Entity, Relation } from '../../src/types/brainy.types.js'
|
||
|
|
|
||
|
|
describe('EventBus', () => {
|
||
|
|
let eventBus: EventBus
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
eventBus = new EventBus()
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('subscribe and emit', () => {
|
||
|
|
it('should emit events to subscribers', async () => {
|
||
|
|
const handler = vi.fn()
|
||
|
|
|
||
|
|
eventBus.subscribe({}, handler)
|
||
|
|
|
||
|
|
eventBus.emit({
|
||
|
|
entityType: 'noun',
|
||
|
|
operation: 'create',
|
||
|
|
entityId: 'test-123'
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(handler).toHaveBeenCalledTimes(1)
|
||
|
|
expect(handler).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({
|
||
|
|
entityType: 'noun',
|
||
|
|
operation: 'create',
|
||
|
|
entityId: 'test-123'
|
||
|
|
})
|
||
|
|
)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should filter by entity type', () => {
|
||
|
|
const handler = vi.fn()
|
||
|
|
|
||
|
|
eventBus.subscribe({ entityTypes: ['verb'] }, handler)
|
||
|
|
|
||
|
|
eventBus.emit({
|
||
|
|
entityType: 'noun',
|
||
|
|
operation: 'create',
|
||
|
|
entityId: 'test-123'
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(handler).not.toHaveBeenCalled()
|
||
|
|
|
||
|
|
eventBus.emit({
|
||
|
|
entityType: 'verb',
|
||
|
|
operation: 'create',
|
||
|
|
entityId: 'test-456'
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(handler).toHaveBeenCalledTimes(1)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should filter by operation', () => {
|
||
|
|
const handler = vi.fn()
|
||
|
|
|
||
|
|
eventBus.subscribe({ operations: ['update', 'delete'] }, handler)
|
||
|
|
|
||
|
|
eventBus.emit({
|
||
|
|
entityType: 'noun',
|
||
|
|
operation: 'create',
|
||
|
|
entityId: 'test-123'
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(handler).not.toHaveBeenCalled()
|
||
|
|
|
||
|
|
eventBus.emit({
|
||
|
|
entityType: 'noun',
|
||
|
|
operation: 'update',
|
||
|
|
entityId: 'test-456'
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(handler).toHaveBeenCalledTimes(1)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should filter by noun type', () => {
|
||
|
|
const handler = vi.fn()
|
||
|
|
|
||
|
|
eventBus.subscribe({ nounTypes: [NounType.Person] }, handler)
|
||
|
|
|
||
|
|
eventBus.emit({
|
||
|
|
entityType: 'noun',
|
||
|
|
operation: 'create',
|
||
|
|
entityId: 'test-123',
|
||
|
|
nounType: NounType.Document
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(handler).not.toHaveBeenCalled()
|
||
|
|
|
||
|
|
eventBus.emit({
|
||
|
|
entityType: 'noun',
|
||
|
|
operation: 'create',
|
||
|
|
entityId: 'test-456',
|
||
|
|
nounType: NounType.Person
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(handler).toHaveBeenCalledTimes(1)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('unsubscribe', () => {
|
||
|
|
it('should stop receiving events after unsubscribe', () => {
|
||
|
|
const handler = vi.fn()
|
||
|
|
|
||
|
|
const subscription = eventBus.subscribe({}, handler)
|
||
|
|
|
||
|
|
eventBus.emit({
|
||
|
|
entityType: 'noun',
|
||
|
|
operation: 'create',
|
||
|
|
entityId: 'test-123'
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(handler).toHaveBeenCalledTimes(1)
|
||
|
|
|
||
|
|
subscription.unsubscribe()
|
||
|
|
|
||
|
|
eventBus.emit({
|
||
|
|
entityType: 'noun',
|
||
|
|
operation: 'create',
|
||
|
|
entityId: 'test-456'
|
||
|
|
})
|
||
|
|
|
||
|
|
expect(handler).toHaveBeenCalledTimes(1) // Still 1
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('sequence IDs', () => {
|
||
|
|
it('should increment sequence IDs', () => {
|
||
|
|
const events: any[] = []
|
||
|
|
eventBus.subscribe({}, (e) => events.push(e))
|
||
|
|
|
||
|
|
eventBus.emit({ entityType: 'noun', operation: 'create', entityId: '1' })
|
||
|
|
eventBus.emit({ entityType: 'noun', operation: 'create', entityId: '2' })
|
||
|
|
eventBus.emit({ entityType: 'noun', operation: 'create', entityId: '3' })
|
||
|
|
|
||
|
|
expect(events[0].sequenceId).toBe(1n)
|
||
|
|
expect(events[1].sequenceId).toBe(2n)
|
||
|
|
expect(events[2].sequenceId).toBe(3n)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should return current sequence ID', () => {
|
||
|
|
expect(eventBus.getCurrentSequenceId()).toBe(0n)
|
||
|
|
|
||
|
|
eventBus.emit({ entityType: 'noun', operation: 'create', entityId: '1' })
|
||
|
|
expect(eventBus.getCurrentSequenceId()).toBe(1n)
|
||
|
|
|
||
|
|
eventBus.emit({ entityType: 'noun', operation: 'create', entityId: '2' })
|
||
|
|
expect(eventBus.getCurrentSequenceId()).toBe(2n)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('event buffer', () => {
|
||
|
|
it('should buffer events for replay', () => {
|
||
|
|
eventBus.emit({ entityType: 'noun', operation: 'create', entityId: '1' })
|
||
|
|
eventBus.emit({ entityType: 'noun', operation: 'create', entityId: '2' })
|
||
|
|
eventBus.emit({ entityType: 'noun', operation: 'create', entityId: '3' })
|
||
|
|
|
||
|
|
const events = eventBus.getEventsSince(1n)
|
||
|
|
|
||
|
|
expect(events).toHaveLength(2)
|
||
|
|
expect(events[0].entityId).toBe('2')
|
||
|
|
expect(events[1].entityId).toBe('3')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('helper methods', () => {
|
||
|
|
it('should emit noun events', () => {
|
||
|
|
const handler = vi.fn()
|
||
|
|
eventBus.subscribe({}, handler)
|
||
|
|
|
||
|
|
eventBus.emitNoun('create', 'entity-1', NounType.Person)
|
||
|
|
|
||
|
|
expect(handler).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({
|
||
|
|
entityType: 'noun',
|
||
|
|
operation: 'create',
|
||
|
|
entityId: 'entity-1',
|
||
|
|
nounType: NounType.Person
|
||
|
|
})
|
||
|
|
)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should emit verb events', () => {
|
||
|
|
const handler = vi.fn()
|
||
|
|
eventBus.subscribe({}, handler)
|
||
|
|
|
||
|
|
eventBus.emitVerb('create', 'rel-1', VerbType.RelatedTo)
|
||
|
|
|
||
|
|
expect(handler).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({
|
||
|
|
entityType: 'verb',
|
||
|
|
operation: 'create',
|
||
|
|
entityId: 'rel-1',
|
||
|
|
verbType: VerbType.RelatedTo
|
||
|
|
})
|
||
|
|
)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should emit VFS events', () => {
|
||
|
|
const handler = vi.fn()
|
||
|
|
eventBus.subscribe({}, handler)
|
||
|
|
|
||
|
|
eventBus.emitVFS('create', 'file-1')
|
||
|
|
|
||
|
|
expect(handler).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({
|
||
|
|
entityType: 'vfs',
|
||
|
|
operation: 'create',
|
||
|
|
entityId: 'file-1'
|
||
|
|
})
|
||
|
|
)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('TabularExporter', () => {
|
||
|
|
let exporter: TabularExporter
|
||
|
|
|
||
|
|
const mockEntity: Entity = {
|
||
|
|
id: 'entity-123',
|
||
|
|
type: NounType.Person,
|
||
|
|
createdAt: 1704067200000, // 2024-01-01
|
||
|
|
updatedAt: 1704153600000, // 2024-01-02
|
||
|
|
vector: new Float32Array([0.1, 0.2, 0.3]),
|
||
|
|
confidence: 0.95,
|
||
|
|
weight: 0.8,
|
||
|
|
service: 'test-service',
|
||
|
|
data: { content: 'Hello world' },
|
||
|
|
metadata: {
|
||
|
|
name: 'John Doe',
|
||
|
|
email: 'john@example.com',
|
||
|
|
nested: { key: 'value' }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const mockRelation: Relation = {
|
||
|
|
id: 'rel-123',
|
||
|
|
from: 'entity-1',
|
||
|
|
to: 'entity-2',
|
||
|
|
type: VerbType.RelatedTo,
|
||
|
|
weight: 0.9,
|
||
|
|
confidence: 0.85,
|
||
|
|
createdAt: 1704067200000,
|
||
|
|
service: 'test-service',
|
||
|
|
metadata: { reason: 'test' }
|
||
|
|
}
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
exporter = new TabularExporter()
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('entitiesToRows', () => {
|
||
|
|
it('should convert entity to row', () => {
|
||
|
|
const rows = exporter.entitiesToRows([mockEntity])
|
||
|
|
|
||
|
|
expect(rows).toHaveLength(1)
|
||
|
|
expect(rows[0].Id).toBe('entity-123')
|
||
|
|
expect(rows[0].Type).toBe('person')
|
||
|
|
expect(rows[0].Confidence).toBe(0.95)
|
||
|
|
expect(rows[0].Weight).toBe(0.8)
|
||
|
|
expect(rows[0].Service).toBe('test-service')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should flatten metadata by default', () => {
|
||
|
|
const rows = exporter.entitiesToRows([mockEntity])
|
||
|
|
|
||
|
|
expect(rows[0].Metadata_name).toBe('John Doe')
|
||
|
|
expect(rows[0].Metadata_email).toBe('john@example.com')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should JSON stringify nested metadata', () => {
|
||
|
|
const rows = exporter.entitiesToRows([mockEntity])
|
||
|
|
|
||
|
|
expect(rows[0].Metadata_nested).toBe('{"key":"value"}')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should JSON stringify data field', () => {
|
||
|
|
const rows = exporter.entitiesToRows([mockEntity])
|
||
|
|
|
||
|
|
expect(rows[0].Data).toBe('{"content":"Hello world"}')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should format dates as ISO 8601', () => {
|
||
|
|
const rows = exporter.entitiesToRows([mockEntity])
|
||
|
|
|
||
|
|
expect(rows[0].CreatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('configuration', () => {
|
||
|
|
it('should not flatten metadata when disabled', () => {
|
||
|
|
exporter = new TabularExporter({ flattenMetadata: false })
|
||
|
|
const rows = exporter.entitiesToRows([mockEntity])
|
||
|
|
|
||
|
|
expect(rows[0].Metadata_name).toBeUndefined()
|
||
|
|
expect(rows[0].Metadata).toBeDefined()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should include vectors when enabled', () => {
|
||
|
|
exporter = new TabularExporter({ includeVectors: true })
|
||
|
|
const rows = exporter.entitiesToRows([mockEntity])
|
||
|
|
|
||
|
|
expect(rows[0].Vector).toBeDefined()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should use custom metadata prefix', () => {
|
||
|
|
exporter = new TabularExporter({ metadataPrefix: 'M_' })
|
||
|
|
const rows = exporter.entitiesToRows([mockEntity])
|
||
|
|
|
||
|
|
expect(rows[0].M_name).toBe('John Doe')
|
||
|
|
expect(rows[0].Metadata_name).toBeUndefined()
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should format dates as unix timestamps', () => {
|
||
|
|
exporter = new TabularExporter({ dateFormat: 'unix' })
|
||
|
|
const rows = exporter.entitiesToRows([mockEntity])
|
||
|
|
|
||
|
|
expect(rows[0].CreatedAt).toBe('1704067200')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('relationsToRows', () => {
|
||
|
|
it('should convert relation to row', () => {
|
||
|
|
const rows = exporter.relationsToRows([mockRelation])
|
||
|
|
|
||
|
|
expect(rows).toHaveLength(1)
|
||
|
|
expect(rows[0].Id).toBe('rel-123')
|
||
|
|
expect(rows[0].FromId).toBe('entity-1')
|
||
|
|
expect(rows[0].ToId).toBe('entity-2')
|
||
|
|
expect(rows[0].Type).toBe('relatedTo')
|
||
|
|
expect(rows[0].Weight).toBe(0.9)
|
||
|
|
expect(rows[0].Confidence).toBe(0.85)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('toCSV', () => {
|
||
|
|
it('should generate valid CSV', () => {
|
||
|
|
const csv = exporter.toCSV([mockEntity])
|
||
|
|
const lines = csv.split('\n')
|
||
|
|
|
||
|
|
expect(lines.length).toBeGreaterThan(1)
|
||
|
|
expect(lines[0]).toContain('Id')
|
||
|
|
expect(lines[0]).toContain('Type')
|
||
|
|
expect(lines[1]).toContain('entity-123')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should escape special characters', () => {
|
||
|
|
const entityWithComma: Entity = {
|
||
|
|
...mockEntity,
|
||
|
|
metadata: { name: 'Doe, John' }
|
||
|
|
}
|
||
|
|
|
||
|
|
const csv = exporter.toCSV([entityWithComma])
|
||
|
|
|
||
|
|
expect(csv).toContain('"Doe, John"')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should escape quotes', () => {
|
||
|
|
const entityWithQuotes: Entity = {
|
||
|
|
...mockEntity,
|
||
|
|
metadata: { name: 'John "Jack" Doe' }
|
||
|
|
}
|
||
|
|
|
||
|
|
const csv = exporter.toCSV([entityWithQuotes])
|
||
|
|
|
||
|
|
expect(csv).toContain('""Jack""')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('parseCSV', () => {
|
||
|
|
it('should parse CSV back to entities', () => {
|
||
|
|
const csv = exporter.toCSV([mockEntity])
|
||
|
|
const entities = exporter.parseCSV(csv)
|
||
|
|
|
||
|
|
expect(entities).toHaveLength(1)
|
||
|
|
expect(entities[0].id).toBe('entity-123')
|
||
|
|
expect(entities[0].type).toBe('person')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('toOData', () => {
|
||
|
|
it('should generate OData response', () => {
|
||
|
|
const odata = exporter.toOData([mockEntity])
|
||
|
|
|
||
|
|
expect(odata).toHaveProperty('@odata.context')
|
||
|
|
expect(odata).toHaveProperty('value')
|
||
|
|
expect((odata as any).value).toHaveLength(1)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should include count when provided', () => {
|
||
|
|
const odata = exporter.toOData([mockEntity], { count: 100 })
|
||
|
|
|
||
|
|
expect((odata as any)['@odata.count']).toBe(100)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('should include nextLink when provided', () => {
|
||
|
|
const odata = exporter.toOData([mockEntity], {
|
||
|
|
nextLink: '/odata/Entities?$skip=100'
|
||
|
|
})
|
||
|
|
|
||
|
|
expect((odata as any)['@odata.nextLink']).toBe('/odata/Entities?$skip=100')
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('getSchema', () => {
|
||
|
|
it('should infer schema from entities', () => {
|
||
|
|
const schema = exporter.getSchema([mockEntity])
|
||
|
|
|
||
|
|
expect(schema).toContainEqual(
|
||
|
|
expect.objectContaining({ name: 'Id', type: 'string' })
|
||
|
|
)
|
||
|
|
expect(schema).toContainEqual(
|
||
|
|
expect.objectContaining({ name: 'Confidence', type: 'number' })
|
||
|
|
)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|