ef5a84a5d5
Enrich ExtensionContext with supabase, emit(), settings, storage, log, and services so extensions can receive everything through dependency injection instead of importing core modules directly. - Add context factory and inject context into event handlers via registry - Move supplier invoice journal entry creation to core event handler - Add services.ingestTransactions to ExtensionContext for enable-banking - Create catch-all API route for extension-declared apiRoutes - Migrate 5 extensions to accept context with dynamic import fallbacks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
162 lines
4.5 KiB
TypeScript
162 lines
4.5 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { extensionRegistry, setContextFactory } from '../registry'
|
|
import { eventBus } from '@/lib/events/bus'
|
|
import type { Extension, ExtensionContext } from '../types'
|
|
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: vi.fn().mockResolvedValue({
|
|
from: vi.fn(),
|
|
storage: { from: vi.fn() },
|
|
}),
|
|
}))
|
|
|
|
beforeEach(() => {
|
|
extensionRegistry.clear()
|
|
eventBus.clear()
|
|
})
|
|
|
|
function makeExtension(overrides: Partial<Extension> = {}): Extension {
|
|
return {
|
|
id: 'test-ext',
|
|
name: 'Test Extension',
|
|
version: '1.0.0',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('ExtensionRegistry', () => {
|
|
it('register() stores extension, queryable via get() and getAll()', () => {
|
|
const ext = makeExtension()
|
|
extensionRegistry.register(ext)
|
|
|
|
expect(extensionRegistry.get('test-ext')).toBe(ext)
|
|
expect(extensionRegistry.getAll()).toEqual([ext])
|
|
})
|
|
|
|
it('register() wires event handlers to the bus', async () => {
|
|
const handler = vi.fn()
|
|
const ext = makeExtension({
|
|
id: 'event-ext',
|
|
eventHandlers: [{ eventType: 'journal_entry.committed', handler }],
|
|
})
|
|
|
|
extensionRegistry.register(ext)
|
|
|
|
await eventBus.emit({
|
|
type: 'journal_entry.committed',
|
|
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
|
|
})
|
|
|
|
expect(handler).toHaveBeenCalled()
|
|
expect(handler.mock.calls[0][0]).toEqual({ entry: { id: 'e1' }, userId: 'u1' })
|
|
})
|
|
|
|
it('register() skips duplicate registration (same id)', () => {
|
|
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
const ext1 = makeExtension()
|
|
const ext2 = makeExtension({ name: 'Duplicate' })
|
|
|
|
extensionRegistry.register(ext1)
|
|
extensionRegistry.register(ext2)
|
|
|
|
// Original is kept
|
|
expect(extensionRegistry.get('test-ext')!.name).toBe('Test Extension')
|
|
expect(extensionRegistry.getAll()).toHaveLength(1)
|
|
|
|
consoleSpy.mockRestore()
|
|
})
|
|
|
|
it('unregister() removes extension and unsubscribes handlers', async () => {
|
|
const handler = vi.fn()
|
|
const ext = makeExtension({
|
|
id: 'removable',
|
|
eventHandlers: [{ eventType: 'journal_entry.committed', handler }],
|
|
})
|
|
|
|
extensionRegistry.register(ext)
|
|
extensionRegistry.unregister('removable')
|
|
|
|
expect(extensionRegistry.get('removable')).toBeUndefined()
|
|
|
|
await eventBus.emit({
|
|
type: 'journal_entry.committed',
|
|
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
|
|
})
|
|
|
|
expect(handler).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('getByCapability() filters correctly', () => {
|
|
const ext1 = makeExtension({
|
|
id: 'with-settings',
|
|
settingsPanel: { label: 'Test', path: '/test' },
|
|
})
|
|
const ext2 = makeExtension({ id: 'without-settings' })
|
|
|
|
extensionRegistry.register(ext1)
|
|
extensionRegistry.register(ext2)
|
|
|
|
const withSettings = extensionRegistry.getByCapability('settingsPanel')
|
|
expect(withSettings).toHaveLength(1)
|
|
expect(withSettings[0].id).toBe('with-settings')
|
|
})
|
|
|
|
it('clear() removes all extensions and unsubscribes all handlers', async () => {
|
|
const handler1 = vi.fn()
|
|
const handler2 = vi.fn()
|
|
|
|
extensionRegistry.register(
|
|
makeExtension({
|
|
id: 'ext1',
|
|
eventHandlers: [{ eventType: 'journal_entry.committed', handler: handler1 }],
|
|
})
|
|
)
|
|
extensionRegistry.register(
|
|
makeExtension({
|
|
id: 'ext2',
|
|
eventHandlers: [{ eventType: 'journal_entry.drafted', handler: handler2 }],
|
|
})
|
|
)
|
|
|
|
extensionRegistry.clear()
|
|
|
|
expect(extensionRegistry.getAll()).toHaveLength(0)
|
|
|
|
await eventBus.emit({
|
|
type: 'journal_entry.committed',
|
|
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
|
|
})
|
|
await eventBus.emit({
|
|
type: 'journal_entry.drafted',
|
|
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
|
|
})
|
|
|
|
expect(handler1).not.toHaveBeenCalled()
|
|
expect(handler2).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('handler receives ExtensionContext when context factory is set', async () => {
|
|
const handler = vi.fn()
|
|
const mockCtx = { extensionId: 'ctx-ext', userId: 'u1' } as ExtensionContext
|
|
|
|
setContextFactory((_supabase, _userId, _extId) => mockCtx)
|
|
|
|
const ext = makeExtension({
|
|
id: 'ctx-ext',
|
|
eventHandlers: [{ eventType: 'journal_entry.committed', handler }],
|
|
})
|
|
|
|
extensionRegistry.register(ext)
|
|
|
|
await eventBus.emit({
|
|
type: 'journal_entry.committed',
|
|
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
|
|
})
|
|
|
|
expect(handler).toHaveBeenCalledWith(
|
|
{ entry: { id: 'e1' }, userId: 'u1' },
|
|
mockCtx
|
|
)
|
|
})
|
|
})
|