Files
accounted/app/api/invoices/recurring/__tests__/route.test.ts
T
Jakob WennbergandClaude Fable 5 951b33363b feat(recurring): carry dimension bags on recurring invoice schedules (#1272)
Schedules and their template items now store {sie_dim_no: code} bags
(default_dimensions / dimensions), and the cron generator copies them
onto every spawned invoice + item, so recurring invoices book with the
same projekt/kostnadsstalle tags a manual invoice would. Wired through
the web CRUD routes, the staged-operation executors, and the MCP
create/update/list schedule tools (resolve-don't-select, resolutions
echoed in the preview).

Migration 20260728090000 adds the two jsonb columns (same shape+CHECK
as invoices/invoice_items, PR7 producer parity).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 09:38:52 +02:00

258 lines
8.6 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('rejects auto_send when the customer has no email', async () => {
// customer lookup: exists but without email
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000', email: null }, error: 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: true,
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(400)
expect(body.type).toBe('validation_error')
})
it('rejects a malformed dimensions bag with 400', 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,
// Key must be a SIE dim number: 'projekt' is not.
default_dimensions: { projekt: 'P001' },
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(400)
expect(body.type).toBe('validation_error')
})
it('persists schedule and item dimension bags', async () => {
// The queued mock's chain proxy discards call args by design, so capture
// .insert/.update payloads per table with a thin wrapper.
const inserted: Record<string, unknown[]> = {}
const originalFrom = mockSupabase.from.getMockImplementation()!
mockSupabase.from.mockImplementation((table: string) => {
const chain = originalFrom(table) as object
return new Proxy(chain, {
get(target, prop, receiver) {
if (prop === 'insert') {
return (rows: unknown) => {
;(inserted[table] ??= []).push(rows)
return (Reflect.get(target, prop, receiver) as (r: unknown) => unknown)(rows)
}
}
return Reflect.get(target, prop, receiver)
},
})
})
const createdSchedule = { id: 's-1', name: 'Acme retainer' }
enqueue({ data: { id: '550e8400-e29b-41d4-a716-446655440000' }, error: null })
enqueue({ data: createdSchedule, error: null })
enqueue({ data: null, error: null })
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,
default_dimensions: { '1': 'KS1', '6': 'P001' },
items: [
{
description: 'Konsultarvode',
quantity: 10,
unit: 'tim',
unit_price: 1200,
dimensions: { '6': 'P002' },
},
{ description: 'Resor', quantity: 1, unit: 'st', unit_price: 500 },
],
},
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(201)
expect(inserted['recurring_invoice_schedules'][0]).toMatchObject({
default_dimensions: { '1': 'KS1', '6': 'P001' },
})
const itemRows = inserted['recurring_invoice_schedule_items'][0] as Array<
Record<string, unknown>
>
expect(itemRows[0].dimensions).toEqual({ '6': 'P002' })
expect(itemRows[1].dimensions).toEqual({})
})
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')
})
})