feat: make extension system packageable via enriched ExtensionContext
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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
59d935f2cc
commit
ef5a84a5d5
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createExtensionContext } from '../context-factory'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createMockSupabase } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/transactions/ingest', () => ({
|
||||
ingestTransactions: vi.fn().mockResolvedValue({
|
||||
imported: 0, duplicates: 0, reconciled: 0,
|
||||
auto_categorized: 0, auto_matched_invoices: 0, errors: 0,
|
||||
transaction_ids: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
eventBus.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('createExtensionContext', () => {
|
||||
it('returns context with correct userId and extensionId', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
expect(ctx.userId).toBe('user-1')
|
||||
expect(ctx.extensionId).toBe('test-ext')
|
||||
})
|
||||
|
||||
it('provides supabase client', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
expect(ctx.supabase).toBe(supabase)
|
||||
})
|
||||
|
||||
it('emit() delegates to eventBus.emit()', async () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
const handler = vi.fn()
|
||||
eventBus.on('journal_entry.committed', handler)
|
||||
|
||||
await ctx.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: { id: 'e1' } as never, userId: 'user-1' },
|
||||
})
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ entry: { id: 'e1' }, userId: 'user-1' })
|
||||
})
|
||||
|
||||
it('log methods prefix with extensionId', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'my-ext')
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
ctx.log.info('hello', 42)
|
||||
ctx.log.warn('caution')
|
||||
ctx.log.error('oops')
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith('[my-ext]', 'hello', 42)
|
||||
expect(warnSpy).toHaveBeenCalledWith('[my-ext]', 'caution')
|
||||
expect(errorSpy).toHaveBeenCalledWith('[my-ext]', 'oops')
|
||||
|
||||
logSpy.mockRestore()
|
||||
warnSpy.mockRestore()
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('settings.get() queries extension_data table', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: { value: { autoOcr: true } }, error: null })
|
||||
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'receipt-ocr')
|
||||
const result = await ctx.settings.get<{ autoOcr: boolean }>('settings')
|
||||
|
||||
expect(result).toEqual({ autoOcr: true })
|
||||
expect(supabase.from).toHaveBeenCalledWith('extension_data')
|
||||
})
|
||||
|
||||
it('settings.get() without key defaults to "settings"', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: { value: { foo: 'bar' } }, error: null })
|
||||
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
const result = await ctx.settings.get<{ foo: string }>()
|
||||
|
||||
expect(result).toEqual({ foo: 'bar' })
|
||||
})
|
||||
|
||||
it('settings.get() returns null when no data', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: null, error: null })
|
||||
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
const result = await ctx.settings.get('missing-key')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('settings.set() upserts into extension_data table', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: null, error: null })
|
||||
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
await ctx.settings.set('my-key', { value: 123 })
|
||||
|
||||
expect(supabase.from).toHaveBeenCalledWith('extension_data')
|
||||
})
|
||||
|
||||
it('storage.getPublicUrl() returns URL string', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
const url = ctx.storage.getPublicUrl('documents', 'path/to/file.pdf')
|
||||
expect(typeof url).toBe('string')
|
||||
})
|
||||
|
||||
it('services.ingestTransactions is a function', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
expect(typeof ctx.services.ingestTransactions).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { extensionRegistry } from '../registry'
|
||||
import { extensionRegistry, setContextFactory } from '../registry'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { Extension } from '../types'
|
||||
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()
|
||||
@@ -40,7 +47,8 @@ describe('ExtensionRegistry', () => {
|
||||
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ entry: { id: 'e1' }, userId: 'u1' })
|
||||
expect(handler).toHaveBeenCalled()
|
||||
expect(handler.mock.calls[0][0]).toEqual({ entry: { id: 'e1' }, userId: 'u1' })
|
||||
})
|
||||
|
||||
it('register() skips duplicate registration (same id)', () => {
|
||||
@@ -126,4 +134,28 @@ describe('ExtensionRegistry', () => {
|
||||
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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user