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:
Jakob Wennberg
2026-02-23 09:24:21 +01:00
co-authored by Claude Opus 4.6
parent 59d935f2cc
commit ef5a84a5d5
24 changed files with 1058 additions and 185 deletions
@@ -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')
})
})
+35 -3
View File
@@ -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
)
})
})