- 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>
247 lines
8.3 KiB
TypeScript
247 lines
8.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import {
|
|
createMockRequest,
|
|
parseJsonResponse,
|
|
createMockRouteParams,
|
|
createQueuedMockSupabase,
|
|
makeInvoice,
|
|
makeCustomer,
|
|
makeCompanySettings,
|
|
} from '@/tests/helpers'
|
|
import { eventBus } from '@/lib/events'
|
|
|
|
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: () => Promise.resolve(mockSupabase),
|
|
}))
|
|
|
|
vi.mock('@/lib/init', () => ({
|
|
ensureInitialized: vi.fn(),
|
|
}))
|
|
|
|
const mockRenderToBuffer = vi.fn()
|
|
vi.mock('@react-pdf/renderer', () => ({
|
|
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
|
|
Document: vi.fn(),
|
|
Page: vi.fn(),
|
|
Text: vi.fn(),
|
|
View: vi.fn(),
|
|
StyleSheet: { create: (s: unknown) => s },
|
|
}))
|
|
|
|
vi.mock('@/lib/invoices/pdf-template', () => ({
|
|
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
|
|
}))
|
|
|
|
const mockSendEmail = vi.fn()
|
|
const mockIsConfigured = vi.fn()
|
|
vi.mock('@/lib/email/service', () => ({
|
|
getEmailService: () => ({
|
|
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
|
|
isConfigured: () => mockIsConfigured(),
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/email/invoice-templates', () => ({
|
|
generateInvoiceEmailHtml: vi.fn().mockReturnValue('<html>Invoice</html>'),
|
|
generateInvoiceEmailText: vi.fn().mockReturnValue('Invoice text'),
|
|
generateInvoiceEmailSubject: vi.fn().mockReturnValue('Faktura F-2024001'),
|
|
}))
|
|
|
|
const mockCreateInvoiceJournalEntry = vi.fn()
|
|
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
|
createInvoiceJournalEntry: (...args: unknown[]) =>
|
|
mockCreateInvoiceJournalEntry(...args),
|
|
}))
|
|
|
|
import { POST } from '../route'
|
|
|
|
describe('POST /api/invoices/[id]/send', () => {
|
|
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
|
const customer = makeCustomer({ id: 'cust-1', email: 'kund@test.se' })
|
|
const company = makeCompanySettings({ accounting_method: 'accrual' })
|
|
const invoice = makeInvoice({
|
|
id: 'inv-1',
|
|
status: 'draft',
|
|
customer,
|
|
items: [
|
|
{
|
|
id: 'item-1',
|
|
invoice_id: 'inv-1',
|
|
sort_order: 0,
|
|
description: 'Consulting',
|
|
quantity: 10,
|
|
unit: 'tim',
|
|
unit_price: 1000,
|
|
line_total: 10000,
|
|
vat_rate: 25,
|
|
vat_amount: 2500,
|
|
created_at: '2024-06-15T14:30:00Z',
|
|
},
|
|
],
|
|
})
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
eventBus.clear()
|
|
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
|
mockIsConfigured.mockReturnValue(true)
|
|
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
|
|
})
|
|
|
|
it('returns 401 when not authenticated', async () => {
|
|
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse(response)
|
|
|
|
expect(status).toBe(401)
|
|
expect(body).toEqual({ error: 'Unauthorized' })
|
|
})
|
|
|
|
it('returns 503 when email service is not configured', async () => {
|
|
mockIsConfigured.mockReturnValue(false)
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status } = await parseJsonResponse(response)
|
|
|
|
expect(status).toBe(503)
|
|
})
|
|
|
|
it('returns 404 when invoice not found', async () => {
|
|
enqueue({ data: null, error: { message: 'Not found' } })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(404)
|
|
expect(body.error).toBe('Fakturan hittades inte')
|
|
})
|
|
|
|
it('returns 400 when customer has no email', async () => {
|
|
const noEmailInvoice = makeInvoice({
|
|
id: 'inv-1',
|
|
customer: makeCustomer({ email: null }),
|
|
items: [],
|
|
})
|
|
enqueue({ data: noEmailInvoice, error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(400)
|
|
expect(body.error).toContain('e-postadress')
|
|
})
|
|
|
|
it('returns 404 when company settings not found', async () => {
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: null, error: { message: 'Not found' } })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(404)
|
|
expect(body.error).toBe('Företagsinställningar saknas')
|
|
})
|
|
|
|
it('sends invoice email, updates status, creates journal entry for accrual', async () => {
|
|
// Fetch invoice
|
|
enqueue({ data: invoice, error: null })
|
|
// Fetch company settings
|
|
enqueue({ data: company, error: null })
|
|
|
|
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-1' })
|
|
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
|
|
|
// Update invoice status to 'sent'
|
|
enqueue({ data: null, error: null })
|
|
// Update invoice with journal_entry_id
|
|
enqueue({ data: null, error: null })
|
|
|
|
const emitSpy = vi.spyOn(eventBus, 'emit')
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{
|
|
success: boolean
|
|
messageId: string
|
|
}>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.success).toBe(true)
|
|
expect(body.messageId).toBe('msg-1')
|
|
expect(mockSendEmail).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
to: 'kund@test.se',
|
|
subject: 'Faktura F-2024001',
|
|
})
|
|
)
|
|
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
'user-1',
|
|
expect.objectContaining({ id: 'inv-1' }),
|
|
'enskild_firma'
|
|
)
|
|
expect(emitSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'invoice.sent' })
|
|
)
|
|
})
|
|
|
|
it('skips journal entry for cash method', async () => {
|
|
const cashCompany = makeCompanySettings({ accounting_method: 'cash' })
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: cashCompany, error: null })
|
|
|
|
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-2' })
|
|
|
|
// Update invoice status
|
|
enqueue({ data: null, error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.success).toBe(true)
|
|
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not fail when journal entry creation fails (non-blocking)', async () => {
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: company, error: null })
|
|
|
|
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-3' })
|
|
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
|
|
|
|
// Update invoice status
|
|
enqueue({ data: null, error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
|
|
|
expect(status).toBe(200)
|
|
expect(body.success).toBe(true)
|
|
})
|
|
|
|
it('returns 500 when email sending fails', async () => {
|
|
enqueue({ data: invoice, error: null })
|
|
enqueue({ data: company, error: null })
|
|
|
|
mockSendEmail.mockResolvedValue({ success: false, error: 'SMTP error' })
|
|
|
|
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
|
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(status).toBe(500)
|
|
expect(body.error).toContain('SMTP error')
|
|
})
|
|
})
|