Contributed by @joakimhew. Maintainer commits on top: migration re-versioned to 20260904170000 (main's 20260901210000 took the original version), payout batches booked atomically through the create_expense_payout_batch RPC, accounted-api skill regenerated, main merged. Closes #2143.
128 lines
4.5 KiB
TypeScript
128 lines
4.5 KiB
TypeScript
/**
|
|
* Auth-wiring + contract tests for /api/expense-claims (GET list, POST
|
|
* register). The service is mocked; these tests pin the route's 401/403,
|
|
* validation 400s, the result-code → status mapping, and the 201 shape.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { NextResponse } from 'next/server'
|
|
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
|
|
|
const { supabase, reset } = createQueuedMockSupabase()
|
|
|
|
const requireAuthMock = vi.fn()
|
|
vi.mock('@/lib/auth/require-auth', () => ({
|
|
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
}))
|
|
|
|
const requireWriteMock = vi.fn()
|
|
vi.mock('@/lib/auth/require-write', () => ({
|
|
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
|
|
|
const registerMock = vi.fn()
|
|
const listMock = vi.fn()
|
|
vi.mock('@/lib/expenses/expense-claims-service', () => ({
|
|
registerExpenseClaim: (...args: unknown[]) => registerMock(...args),
|
|
listExpenseClaims: (...args: unknown[]) => listMock(...args),
|
|
}))
|
|
|
|
import { GET, POST } from '../route'
|
|
|
|
function post(body: unknown) {
|
|
return createMockRequest('/api/expense-claims', { method: 'POST', body })
|
|
}
|
|
|
|
const validClaim = {
|
|
description: 'USB-hubb',
|
|
expense_date: '2026-09-01',
|
|
amount: 500,
|
|
vat_amount: 100,
|
|
expense_account: '5410',
|
|
claimant_name: 'Joakim Hansson',
|
|
}
|
|
|
|
describe('/api/expense-claims', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
|
requireWriteMock.mockResolvedValue({ ok: true })
|
|
listMock.mockResolvedValue([])
|
|
registerMock.mockResolvedValue({ ok: true, claim: { id: 'claim-1' } })
|
|
})
|
|
|
|
it('GET returns 401 when unauthenticated', async () => {
|
|
requireAuthMock.mockResolvedValue({
|
|
user: null,
|
|
supabase,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
const response = await GET(createMockRequest('/api/expense-claims'), {} as never)
|
|
expect(response.status).toBe(401)
|
|
})
|
|
|
|
it('GET lists claims and passes a valid status filter', async () => {
|
|
listMock.mockResolvedValue([{ id: 'claim-1' }])
|
|
const response = await GET(
|
|
createMockRequest('/api/expense-claims?status=registered'),
|
|
{} as never,
|
|
)
|
|
const { status, body } = await parseJsonResponse<{ data: { id: string }[] }>(response)
|
|
expect(status).toBe(200)
|
|
expect(body.data).toHaveLength(1)
|
|
expect(listMock).toHaveBeenCalledWith(supabase, 'company-1', { status: 'registered' })
|
|
})
|
|
|
|
it('POST returns 403 for a viewer', async () => {
|
|
requireWriteMock.mockResolvedValue({
|
|
ok: false,
|
|
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
|
})
|
|
const response = await POST(post(validClaim), {} as never)
|
|
expect(response.status).toBe(403)
|
|
})
|
|
|
|
it('POST registers a claim (201)', async () => {
|
|
const response = await POST(post(validClaim), {} as never)
|
|
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
|
|
expect(status).toBe(201)
|
|
expect(body.data.id).toBe('claim-1')
|
|
})
|
|
|
|
it('POST rejects VAT >= amount with a field-level 400', async () => {
|
|
const response = await POST(post({ ...validClaim, vat_amount: 500 }), {} as never)
|
|
const { status, body } = await parseJsonResponse<{ errors: { field: string }[] }>(response)
|
|
expect(status).toBe(400)
|
|
expect(body.errors).toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ field: 'vat_amount' })]),
|
|
)
|
|
expect(registerMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('POST rejects a claim without employee or claimant name', async () => {
|
|
const { claimant_name: _omitted, ...rest } = validClaim
|
|
const response = await POST(post(rest), {} as never)
|
|
expect(response.status).toBe(400)
|
|
})
|
|
|
|
it.each([
|
|
['EMPLOYEE_NOT_FOUND', 404],
|
|
['RATE_UNAVAILABLE', 400],
|
|
['FISCAL_PERIOD_NOT_FOUND', 400],
|
|
['CLAIM_INSERT_FAILED', 500],
|
|
] as const)('POST maps service code %s to %d', async (code, expected) => {
|
|
registerMock.mockResolvedValue({ ok: false, code })
|
|
const response = await POST(post(validClaim), {} as never)
|
|
const { status, body } = await parseJsonResponse<{ code: string }>(response)
|
|
expect(status).toBe(expected)
|
|
expect(body.code).toBe(code)
|
|
})
|
|
})
|