* feat(voucher): add create voucher and correct entry previews; update commit methods * feat: add support for pending operations in API key scopes and OAuth client management - Introduced new API key scopes for reading and approving pending operations. - Updated the scope groups to include pending operations. - Added new tools for listing and managing pending operations. - Implemented OAuth client registration and revocation endpoints. - Created a UI panel for managing OAuth clients, including registration and revocation. - Added tests for pending operations tools and OAuth allowlist functionality. - Implemented a database migration for OAuth client registrations with appropriate policies and constraints. * feat: Implement OAuth client registration rate limiting and enhance security measures - Added IP-based rate limiting to the OAuth client registration endpoint to prevent enumeration attacks. - Introduced a service-role client for allowlist lookups, ensuring trust boundaries are maintained. - Updated error responses to be uniform across different types of redirect URI validation failures. - Enhanced tests to reflect changes in OAuth scope handling, ensuring fallback to read-only scopes when no scopes are provided. - Improved handling of high-risk pending operations, requiring explicit confirmation for approvals. - Added audit logging for OAuth client revocations and pending operation approvals/rejections to maintain a security audit trail. - Refactored API key scope management to include default read-only scopes for OAuth-issued keys and added segregation-of-duties checks. * feat: add recurring invoice scheduling functionality - Implemented recurring invoice schedules with a new database schema. - Created API routes for managing recurring invoices (GET and POST). - Added cron job to automatically generate invoices based on schedules. - Developed service functions for computing next run dates and executing schedules. - Added tests for the new functionality, including validation and success cases. - Introduced error handling for various scenarios in the invoice creation process. * feat: refine VAT rate validation and enhance recurring invoice handling
149 lines
4.7 KiB
TypeScript
149 lines
4.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import {
|
|
createMockRequest,
|
|
parseJsonResponse,
|
|
createQueuedMockSupabase,
|
|
} 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(),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/require-write', () => ({
|
|
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
|
}))
|
|
|
|
import { GET, POST } from '../route'
|
|
|
|
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
|
|
|
describe('GET /api/invoices/recurring', () => {
|
|
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 response = await GET(createMockRequest('/api/invoices/recurring'), { params: Promise.resolve({}) })
|
|
const { status } = await parseJsonResponse(response)
|
|
expect(status).toBe(401)
|
|
})
|
|
|
|
it('returns schedule list', async () => {
|
|
const schedules = [
|
|
{ id: 's-1', name: 'Acme retainer', day_of_month: 15, status: 'active' },
|
|
]
|
|
enqueue({ data: schedules, error: null })
|
|
|
|
const response = await GET(createMockRequest('/api/invoices/recurring'), { params: Promise.resolve({}) })
|
|
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response)
|
|
expect(status).toBe(200)
|
|
expect(body.data).toEqual(schedules)
|
|
})
|
|
})
|
|
|
|
describe('POST /api/invoices/recurring', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
eventBus.clear()
|
|
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
|
})
|
|
|
|
it('returns 400 on validation error (missing items)', async () => {
|
|
const request = createMockRequest('/api/invoices/recurring', {
|
|
method: 'POST',
|
|
body: {
|
|
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
name: 'Test',
|
|
day_of_month: 15,
|
|
payment_terms_days: 30,
|
|
currency: 'SEK',
|
|
auto_send: false,
|
|
items: [],
|
|
},
|
|
})
|
|
const response = await POST(request, { params: Promise.resolve({}) })
|
|
const { status, body } = await parseJsonResponse<{ type: string }>(response)
|
|
expect(status).toBe(400)
|
|
expect(body.type).toBe('validation_error')
|
|
})
|
|
|
|
it('returns 404 when customer does not exist', async () => {
|
|
enqueue({ data: null, error: null }) // customer lookup → null
|
|
|
|
const request = createMockRequest('/api/invoices/recurring', {
|
|
method: 'POST',
|
|
body: {
|
|
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
name: 'Test',
|
|
day_of_month: 15,
|
|
payment_terms_days: 30,
|
|
currency: 'SEK',
|
|
auto_send: false,
|
|
items: [
|
|
{ description: 'Service', quantity: 1, unit: 'st', unit_price: 1000 },
|
|
],
|
|
},
|
|
})
|
|
const response = await POST(request, { params: Promise.resolve({}) })
|
|
const { status, body } = await parseJsonResponse<{ type: string }>(response)
|
|
expect(status).toBe(404)
|
|
expect(body.type).toBe('not_found')
|
|
})
|
|
|
|
it('creates a schedule on the happy path', async () => {
|
|
const createdSchedule = {
|
|
id: 's-1',
|
|
company_id: 'company-1',
|
|
user_id: 'user-1',
|
|
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
name: 'Acme retainer',
|
|
day_of_month: 15,
|
|
next_run_date: '2026-05-15',
|
|
status: 'active',
|
|
}
|
|
// 1. customer lookup ok
|
|
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000' }, error: null })
|
|
// 2. schedule insert returns the row
|
|
enqueue({ data: createdSchedule, error: null })
|
|
// 3. items insert ok
|
|
enqueue({ data: null, error: null })
|
|
// 4. final re-fetch
|
|
enqueue({ data: { ...createdSchedule, items: [] }, error: null })
|
|
|
|
const request = createMockRequest('/api/invoices/recurring', {
|
|
method: 'POST',
|
|
body: {
|
|
customer_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
name: 'Acme retainer',
|
|
day_of_month: 15,
|
|
payment_terms_days: 30,
|
|
currency: 'SEK',
|
|
auto_send: false,
|
|
items: [
|
|
{ description: 'Konsultarvode', quantity: 10, unit: 'tim', unit_price: 1200 },
|
|
],
|
|
},
|
|
})
|
|
const response = await POST(request, { params: Promise.resolve({}) })
|
|
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
|
|
expect(status).toBe(201)
|
|
expect(body.data.id).toBe('s-1')
|
|
})
|
|
})
|