test: add API route tests for 10 critical endpoints
Add test coverage for invoices, transactions, bookkeeping, and supplier invoice API routes. Includes test helpers (createMockRequest, parseJsonResponse, createMockRouteParams, createQueuedMockSupabase) and fixture factories (makeInvoice, makeCustomer, makeSupplier, makeSupplierInvoice, makeCompanySettings). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
makeInvoice,
|
||||
makeCustomer,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
const mockCreateInvoicePaymentJournalEntry = vi.fn()
|
||||
const mockCreateInvoiceCashEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createInvoicePaymentJournalEntry: (...args: unknown[]) =>
|
||||
mockCreateInvoicePaymentJournalEntry(...args),
|
||||
createInvoiceCashEntry: (...args: unknown[]) =>
|
||||
mockCreateInvoiceCashEntry(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { 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 404 when invoice not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { 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 invoice is in draft status', async () => {
|
||||
const invoice = makeInvoice({ status: 'draft' })
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { 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).toBe('Fakturan kan inte markeras som betald i nuvarande status')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is already paid', async () => {
|
||||
const invoice = makeInvoice({ status: 'paid' })
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { 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).toBe('Fakturan kan inte markeras som betald i nuvarande status')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is credited', async () => {
|
||||
const invoice = makeInvoice({ status: 'credited' })
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('marks sent invoice as paid with accrual method', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
customer,
|
||||
})
|
||||
|
||||
// Fetch invoice
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Update invoice status
|
||||
enqueue({ data: null, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
status: string
|
||||
paid_amount: number
|
||||
journal_entry_id: string | null
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.status).toBe('paid')
|
||||
expect(body.paid_amount).toBe(12500)
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
it('marks overdue invoice as paid with cash method', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'overdue',
|
||||
total: 12500,
|
||||
customer,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateInvoiceCashEntry.mockResolvedValue({ id: 'je-2' })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: string | null
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-2')
|
||||
expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
expect.any(String),
|
||||
'enskild_firma'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns success with null journal_entry_id when journal entry creation fails', async () => {
|
||||
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: string | null
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,241 @@
|
||||
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/invoice/pdf-template', () => ({
|
||||
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
|
||||
}))
|
||||
|
||||
const mockSendEmail = vi.fn()
|
||||
const mockIsResendConfigured = vi.fn()
|
||||
vi.mock('@/lib/email/resend', () => ({
|
||||
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
|
||||
isResendConfigured: () => mockIsResendConfigured(),
|
||||
}))
|
||||
|
||||
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,
|
||||
created_at: '2024-06-15T14:30:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockIsResendConfigured.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 () => {
|
||||
mockIsResendConfigured.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(
|
||||
'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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,394 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
makeInvoice,
|
||||
makeCustomer,
|
||||
} 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 mockGetVatRules = vi.fn()
|
||||
const mockCalculateVat = vi.fn()
|
||||
vi.mock('@/lib/invoice/vat-rules', () => ({
|
||||
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
|
||||
calculateVat: (...args: unknown[]) => mockCalculateVat(...args),
|
||||
calculateTotal: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
fetchExchangeRate: vi.fn().mockResolvedValue(null),
|
||||
convertToSEK: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCreateCreditNoteJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createCreditNoteJournalEntry: (...args: unknown[]) =>
|
||||
mockCreateCreditNoteJournalEntry(...args),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
describe('GET /api/invoices', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/invoices')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns invoices list', async () => {
|
||||
const invoices = [makeInvoice(), makeInvoice()]
|
||||
enqueue({ data: invoices, error: null, count: 2 })
|
||||
|
||||
const request = createMockRequest('/api/invoices')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown[]; count: number }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(invoices)
|
||||
expect(body.count).toBe(2)
|
||||
})
|
||||
|
||||
it('applies status filter', async () => {
|
||||
enqueue({ data: [], error: null, count: 0 })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
searchParams: { status: 'sent' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('invoices')
|
||||
})
|
||||
|
||||
it('applies pagination', async () => {
|
||||
enqueue({ data: [], error: null, count: 0 })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
searchParams: { limit: '10', offset: '20' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
enqueue({ data: null, error: { message: 'DB error' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('DB error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/invoices (create invoice)', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { customer_id: 'cust-1', items: [] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 404 when customer not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: 'cust-999',
|
||||
invoice_date: '2024-06-15',
|
||||
due_date: '2024-07-15',
|
||||
currency: 'SEK',
|
||||
items: [{ description: 'Test', quantity: 1, unit: 'st', unit_price: 1000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Customer not found')
|
||||
})
|
||||
|
||||
it('creates invoice with items and emits event', async () => {
|
||||
const customer = makeCustomer({ id: 'cust-1' })
|
||||
const createdInvoice = makeInvoice({ id: 'inv-1' })
|
||||
|
||||
mockGetVatRules.mockReturnValue({
|
||||
treatment: 'standard_25',
|
||||
rate: 25,
|
||||
momsRuta: '10',
|
||||
reverseChargeText: null,
|
||||
})
|
||||
mockCalculateVat.mockReturnValue(2500)
|
||||
|
||||
// Fetch customer
|
||||
enqueue({ data: customer, error: null })
|
||||
// RPC generate_invoice_number
|
||||
enqueue({ data: 'F-2024001' })
|
||||
// Insert invoice
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Insert items
|
||||
enqueue({ data: null, error: null })
|
||||
// Fetch complete invoice
|
||||
enqueue({ data: { ...createdInvoice, customer, items: [] }, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: 'cust-1',
|
||||
invoice_date: '2024-06-15',
|
||||
due_date: '2024-07-15',
|
||||
currency: 'SEK',
|
||||
items: [{ description: 'Consulting', quantity: 10, unit: 'tim', unit_price: 1000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toBeTruthy()
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'invoice.created' })
|
||||
)
|
||||
})
|
||||
|
||||
it('rolls back invoice when items insertion fails', async () => {
|
||||
const customer = makeCustomer({ id: 'cust-1' })
|
||||
const createdInvoice = makeInvoice({ id: 'inv-1' })
|
||||
|
||||
mockGetVatRules.mockReturnValue({
|
||||
treatment: 'standard_25',
|
||||
rate: 25,
|
||||
momsRuta: '10',
|
||||
reverseChargeText: null,
|
||||
})
|
||||
mockCalculateVat.mockReturnValue(2500)
|
||||
|
||||
enqueue({ data: customer, error: null })
|
||||
enqueue({ data: 'F-2024001' })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Items insertion fails
|
||||
enqueue({ data: null, error: { message: 'Items insert failed' } })
|
||||
// Rollback delete
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: 'cust-1',
|
||||
invoice_date: '2024-06-15',
|
||||
due_date: '2024-07-15',
|
||||
currency: 'SEK',
|
||||
items: [{ description: 'Test', quantity: 1, unit: 'st', unit_price: 1000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('Items insert failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/invoices (create credit note)', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 404 when original invoice not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-999' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Original invoice not found')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is already credited', async () => {
|
||||
const original = makeInvoice({ id: 'inv-1', status: 'credited' })
|
||||
enqueue({ data: original, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Invoice has already been credited')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is in draft status', async () => {
|
||||
const original = makeInvoice({ id: 'inv-1', status: 'draft' })
|
||||
enqueue({ data: original, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Only sent, paid, or overdue invoices can be credited')
|
||||
})
|
||||
|
||||
it('creates credit note with negated amounts and emits event', async () => {
|
||||
const items = [
|
||||
{
|
||||
id: 'item-1',
|
||||
invoice_id: 'inv-1',
|
||||
sort_order: 0,
|
||||
description: 'Consulting',
|
||||
quantity: 10,
|
||||
unit: 'tim',
|
||||
unit_price: 1000,
|
||||
line_total: 10000,
|
||||
created_at: '2024-06-15T14:30:00Z',
|
||||
},
|
||||
]
|
||||
const original = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
subtotal: 10000,
|
||||
vat_amount: 2500,
|
||||
total: 12500,
|
||||
items,
|
||||
})
|
||||
const creditNote = makeInvoice({
|
||||
id: 'cn-1',
|
||||
credited_invoice_id: 'inv-1',
|
||||
subtotal: -10000,
|
||||
vat_amount: -2500,
|
||||
total: -12500,
|
||||
status: 'sent',
|
||||
})
|
||||
|
||||
// Fetch original invoice
|
||||
enqueue({ data: original, error: null })
|
||||
// Insert credit note
|
||||
enqueue({ data: creditNote, error: null })
|
||||
// Insert credit note items
|
||||
enqueue({ data: null, error: null })
|
||||
// Update original status to 'credited'
|
||||
enqueue({ data: null, error: null })
|
||||
// Fetch complete credit note
|
||||
enqueue({ data: { ...creditNote, items: [] }, error: null })
|
||||
// Fetch company settings for entity type
|
||||
enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
// Update credit note with journal_entry_id
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toBeTruthy()
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'credit_note.created' })
|
||||
)
|
||||
})
|
||||
|
||||
it('rolls back credit note when items insertion fails', async () => {
|
||||
const original = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
invoice_id: 'inv-1',
|
||||
sort_order: 0,
|
||||
description: 'Test',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 1000,
|
||||
line_total: 1000,
|
||||
created_at: '2024-06-15T14:30:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
const creditNote = makeInvoice({ id: 'cn-1' })
|
||||
|
||||
enqueue({ data: original, error: null })
|
||||
enqueue({ data: creditNote, error: null })
|
||||
// Items fail
|
||||
enqueue({ data: null, error: { message: 'Items insert failed' } })
|
||||
// Rollback delete
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('Items insert failed')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user