03b569d708
- Remove all sector-specific extensions (construction, ecommerce, export, hotel, restaurant, tech) — only general-purpose extensions remain - Move NE-bilaga and SRU export from extensions to core reports (lib/reports/) - Move moms-box-mapping from extensions/export/shared to lib/vat/ - Replace per-extension API routes with catch-all dispatcher (app/api/extensions/ext/[...path]/route.ts) - Add manifest.json for each extension with metadata, env vars, and deps - Add api-routes.ts pattern for extension-defined API endpoints - Add code generation scripts (generate-extension-registry, create-extension) - Add extensions.config.json for opt-in extension loading - Add extensions.schema.json for config validation - Add email service interface with noop default (lib/email/service.ts) - Add CI workflow (core-build.yml) to verify core builds with zero extensions - Add migration 045: expand account_type CHECK for untaxed_reserves - Update CLAUDE.md with comprehensive extension system documentation - Update all report engines and bookkeeping services for new imports - Clean up extensions.schema.json to only list existing extensions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
234 lines
7.3 KiB
TypeScript
234 lines
7.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { makeTransaction, createMockSupabase } from '@/tests/helpers'
|
|
import { BOOKING_TEMPLATES } from '@/lib/bookkeeping/booking-templates'
|
|
|
|
// Mock server-only (no-op in tests)
|
|
vi.mock('server-only', () => ({}))
|
|
|
|
// Mock OpenAI Embeddings
|
|
vi.mock('@langchain/openai', () => {
|
|
class MockOpenAIEmbeddings {
|
|
embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1))
|
|
embedDocuments = vi.fn().mockImplementation((texts: string[]) =>
|
|
Promise.resolve(texts.map(() => new Array(1536).fill(0.1)))
|
|
)
|
|
}
|
|
return { OpenAIEmbeddings: MockOpenAIEmbeddings }
|
|
})
|
|
|
|
// Mock Supabase
|
|
const { supabase: mockSupabase, mockResult } = createMockSupabase()
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createServiceClient: vi.fn().mockResolvedValue(mockSupabase),
|
|
}))
|
|
|
|
describe('template-embeddings', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('buildEmbeddingText', () => {
|
|
it('includes all relevant fields for a template', async () => {
|
|
const { buildEmbeddingText } = await import('../template-embeddings')
|
|
|
|
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
|
|
const text = buildEmbeddingText(template)
|
|
|
|
// Should include Swedish and English name
|
|
expect(text).toContain('Lokalhyra')
|
|
expect(text).toContain('Office rent')
|
|
|
|
// Should include description
|
|
expect(text).toContain(template.description_sv)
|
|
|
|
// Should include keywords
|
|
expect(text).toContain('hyra')
|
|
expect(text).toContain('lokal')
|
|
|
|
// Should include group
|
|
expect(text).toContain('premises')
|
|
|
|
// Should include direction
|
|
expect(text).toContain('utgift')
|
|
|
|
// Should include accounts
|
|
expect(text).toContain('5010')
|
|
expect(text).toContain('1930')
|
|
})
|
|
|
|
it('includes VAT treatment when present', async () => {
|
|
const { buildEmbeddingText } = await import('../template-embeddings')
|
|
|
|
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
|
|
const text = buildEmbeddingText(template)
|
|
|
|
expect(text).toContain('standard_25')
|
|
expect(text).toContain('25%')
|
|
})
|
|
|
|
it('includes special rules when present', async () => {
|
|
const { buildEmbeddingText } = await import('../template-embeddings')
|
|
|
|
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
|
|
const text = buildEmbeddingText(template)
|
|
|
|
expect(text).toContain(template.special_rules_sv!)
|
|
})
|
|
|
|
it('includes MCC codes when present', async () => {
|
|
const { buildEmbeddingText } = await import('../template-embeddings')
|
|
|
|
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
|
|
const text = buildEmbeddingText(template)
|
|
|
|
expect(text).toContain('4900')
|
|
})
|
|
|
|
it('includes deductibility note for non-full deductibility', async () => {
|
|
const { buildEmbeddingText } = await import('../template-embeddings')
|
|
|
|
const template = BOOKING_TEMPLATES.find((t) => t.deductibility === 'non_deductible')!
|
|
const text = buildEmbeddingText(template)
|
|
|
|
expect(text).toContain('non_deductible')
|
|
})
|
|
|
|
it('generates text for all templates without error', async () => {
|
|
const { buildEmbeddingText } = await import('../template-embeddings')
|
|
|
|
for (const template of BOOKING_TEMPLATES) {
|
|
const text = buildEmbeddingText(template)
|
|
expect(text.length).toBeGreaterThan(10)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('buildTransactionQueryText', () => {
|
|
it('combines description, merchant, and direction', async () => {
|
|
const { buildTransactionQueryText } = await import('../template-embeddings')
|
|
|
|
const tx = makeTransaction({
|
|
description: 'SPOTIFY PREMIUM',
|
|
merchant_name: 'Spotify',
|
|
amount: -109,
|
|
mcc_code: 5815,
|
|
})
|
|
|
|
const text = buildTransactionQueryText(tx)
|
|
|
|
expect(text).toContain('SPOTIFY PREMIUM')
|
|
expect(text).toContain('Spotify')
|
|
expect(text).toContain('MCC 5815')
|
|
expect(text).toContain('utgift')
|
|
})
|
|
|
|
it('marks positive amounts as income', async () => {
|
|
const { buildTransactionQueryText } = await import('../template-embeddings')
|
|
|
|
const tx = makeTransaction({
|
|
description: 'Inbetalning',
|
|
amount: 5000,
|
|
})
|
|
|
|
const text = buildTransactionQueryText(tx)
|
|
expect(text).toContain('intäkt')
|
|
})
|
|
|
|
it('handles null merchant_name and mcc_code', async () => {
|
|
const { buildTransactionQueryText } = await import('../template-embeddings')
|
|
|
|
const tx = makeTransaction({
|
|
description: 'Some payment',
|
|
merchant_name: null,
|
|
mcc_code: null,
|
|
amount: -100,
|
|
})
|
|
|
|
const text = buildTransactionQueryText(tx)
|
|
expect(text).toContain('Some payment')
|
|
expect(text).toContain('utgift')
|
|
expect(text).not.toContain('MCC')
|
|
})
|
|
|
|
it('prepends user description when provided', async () => {
|
|
const { buildTransactionQueryText } = await import('../template-embeddings')
|
|
|
|
const tx = makeTransaction({
|
|
description: 'SWE REST 4521 STHLM',
|
|
merchant_name: 'Unknown',
|
|
amount: -450,
|
|
})
|
|
|
|
const text = buildTransactionQueryText(tx, 'business lunch with client')
|
|
|
|
// User description should appear first
|
|
expect(text.indexOf('business lunch with client')).toBe(0)
|
|
// Transaction data should still be present
|
|
expect(text).toContain('SWE REST 4521 STHLM')
|
|
expect(text).toContain('Unknown')
|
|
expect(text).toContain('utgift')
|
|
})
|
|
|
|
it('behaves identically when userDescription is undefined', async () => {
|
|
const { buildTransactionQueryText } = await import('../template-embeddings')
|
|
|
|
const tx = makeTransaction({
|
|
description: 'SPOTIFY PREMIUM',
|
|
merchant_name: 'Spotify',
|
|
amount: -109,
|
|
})
|
|
|
|
const withoutDesc = buildTransactionQueryText(tx)
|
|
const withUndefined = buildTransactionQueryText(tx, undefined)
|
|
|
|
expect(withoutDesc).toBe(withUndefined)
|
|
})
|
|
})
|
|
|
|
describe('getSchemaVersion', () => {
|
|
it('returns a consistent hash string', async () => {
|
|
const { getSchemaVersion } = await import('../template-embeddings')
|
|
|
|
const v1 = getSchemaVersion()
|
|
const v2 = getSchemaVersion()
|
|
|
|
expect(v1).toBe(v2)
|
|
expect(v1).toHaveLength(12)
|
|
expect(v1).toMatch(/^[a-f0-9]+$/)
|
|
})
|
|
})
|
|
|
|
describe('findSimilarTemplates', () => {
|
|
it('returns empty array on RPC error (graceful fallback)', async () => {
|
|
const { findSimilarTemplates } = await import('../template-embeddings')
|
|
|
|
// Mock staleness check
|
|
mockResult({ data: { schema_version: 'test' }, error: null })
|
|
|
|
const tx = makeTransaction({
|
|
description: 'SPOTIFY',
|
|
amount: -109,
|
|
})
|
|
|
|
// The mock will return error for the RPC call
|
|
mockResult({ data: null, error: { message: 'RPC failed' } })
|
|
const results = await findSimilarTemplates(tx)
|
|
expect(results).toEqual([])
|
|
})
|
|
|
|
it('returns empty array when no embeddings exist', async () => {
|
|
const { findSimilarTemplates } = await import('../template-embeddings')
|
|
|
|
mockResult({ data: [], error: null })
|
|
|
|
const tx = makeTransaction({
|
|
description: 'Random purchase',
|
|
amount: -50,
|
|
})
|
|
|
|
const results = await findSimilarTemplates(tx)
|
|
expect(results).toEqual([])
|
|
})
|
|
})
|
|
})
|