Files
accounted/app/api/transactions/__tests__/route.test.ts
T
Jakob Wennberg d11d0a2e90 feat(reconciliation): match one bank event to several verifikationer (1:N) (#1553) (#2029)
One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553
2026-08-30 11:56:15 +02:00

226 lines
8.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
makeTransaction,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
// GET goes through withRouteContext, which resolves the session via requireAuth.
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { NextResponse } from 'next/server'
describe('GET /api/transactions', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
const originalFrom = mockSupabase.from
beforeEach(() => {
vi.clearAllMocks()
reset()
mockSupabase.from = originalFrom
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: mockSupabase as never,
error: null,
})
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: mockSupabase as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/transactions')
const response = await GET(request, createMockRouteParams({}))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns transactions for the active company with has_more=false when below the cap', async () => {
const txs = [
makeTransaction({ id: 'tx-1', amount: -100 }),
makeTransaction({ id: 'tx-2', amount: 250 }),
]
enqueue({ data: txs, error: null })
const request = createMockRequest('/api/transactions')
const response = await GET(request, createMockRouteParams({}))
const { status, body } = await parseJsonResponse<{
data: typeof txs
has_more: boolean
limit: number
}>(response)
expect(status).toBe(200)
expect(body.data).toHaveLength(2)
expect(body.data[0].id).toBe('tx-1')
expect(body.has_more).toBe(false)
expect(body.limit).toBe(500)
})
it('signals has_more=true and truncates to the cap when more rows exist', async () => {
// Server requests MAX_ROWS+1 = 501 rows; if the DB returns 501 we know there's more.
const txs = Array.from({ length: 501 }, (_, i) =>
makeTransaction({ id: `tx-${i}`, amount: i }),
)
enqueue({ data: txs, error: null })
const request = createMockRequest('/api/transactions')
const response = await GET(request, createMockRouteParams({}))
const { status, body } = await parseJsonResponse<{
data: typeof txs
has_more: boolean
limit: number
}>(response)
expect(status).toBe(200)
expect(body.data).toHaveLength(500)
expect(body.has_more).toBe(true)
expect(body.limit).toBe(500)
})
it('filters by unmatched=true', async () => {
const fromSpy = vi.fn(() => {
const chain: Record<string, unknown> = {}
const methods = ['select', 'eq', 'is', 'not', 'gte', 'lte', 'order', 'limit']
const calls: { method: string; args: unknown[] }[] = []
for (const m of methods) {
chain[m] = vi.fn((...args: unknown[]) => {
calls.push({ method: m, args })
return chain
})
}
;(chain as { then: unknown }).then = (resolve: (v: unknown) => void) =>
resolve({ data: [], error: null })
;(chain as { __calls: typeof calls }).__calls = calls
return chain
})
mockSupabase.from = fromSpy as unknown as typeof mockSupabase.from
const request = createMockRequest('/api/transactions?unmatched=true')
await GET(request, createMockRouteParams({}))
expect(fromSpy).toHaveBeenCalledWith('transactions')
const chain = fromSpy.mock.results[0].value as { __calls: { method: string; args: unknown[] }[] }
const isCall = chain.__calls.find((c) => c.method === 'is')
expect(isCall).toEqual({ method: 'is', args: ['journal_entry_id', null] })
})
it('hides rows anchored only through transaction_voucher_links from unmatched=true (bulk-book, 1:N split, #1553)', async () => {
const txs = [
makeTransaction({ id: 'tx-open', journal_entry_id: null }),
makeTransaction({ id: 'tx-split', journal_entry_id: null }),
]
enqueue({ data: txs, error: null }) // transactions list
enqueue({ data: [{ transaction_id: 'tx-split' }], error: null }) // transaction_voucher_links
const request = createMockRequest('/api/transactions?unmatched=true')
const response = await GET(request, createMockRouteParams({}))
const { status, body } = await parseJsonResponse<{ data: Array<{ id: string }>; has_more: boolean }>(response)
expect(status).toBe(200)
expect(body.data.map((t) => t.id)).toEqual(['tx-open'])
expect(body.has_more).toBe(false)
})
it('filters by reconciled=true', async () => {
const fromSpy = vi.fn(() => {
const chain: Record<string, unknown> = {}
const methods = ['select', 'eq', 'is', 'not', 'gte', 'lte', 'order', 'limit']
const calls: { method: string; args: unknown[] }[] = []
for (const m of methods) {
chain[m] = vi.fn((...args: unknown[]) => {
calls.push({ method: m, args })
return chain
})
}
;(chain as { then: unknown }).then = (resolve: (v: unknown) => void) =>
resolve({ data: [], error: null })
;(chain as { __calls: typeof calls }).__calls = calls
return chain
})
mockSupabase.from = fromSpy as unknown as typeof mockSupabase.from
const request = createMockRequest('/api/transactions?reconciled=true')
await GET(request, createMockRouteParams({}))
const chain = fromSpy.mock.results[0].value as { __calls: { method: string; args: unknown[] }[] }
const notCall = chain.__calls.find((c) => c.method === 'not')
expect(notCall).toEqual({ method: 'not', args: ['journal_entry_id', 'is', null] })
// unmatched and reconciled are mutually exclusive: when reconciled is set, no .is() filter
const isCall = chain.__calls.find((c) => c.method === 'is')
expect(isCall).toBeUndefined()
})
it('applies currency, date_from, and date_to filters', async () => {
const fromSpy = vi.fn(() => {
const chain: Record<string, unknown> = {}
const methods = ['select', 'eq', 'is', 'not', 'gte', 'lte', 'order', 'limit']
const calls: { method: string; args: unknown[] }[] = []
for (const m of methods) {
chain[m] = vi.fn((...args: unknown[]) => {
calls.push({ method: m, args })
return chain
})
}
;(chain as { then: unknown }).then = (resolve: (v: unknown) => void) =>
resolve({ data: [], error: null })
;(chain as { __calls: typeof calls }).__calls = calls
return chain
})
mockSupabase.from = fromSpy as unknown as typeof mockSupabase.from
const request = createMockRequest(
'/api/transactions?currency=SEK&date_from=2024-01-01&date_to=2024-12-31'
)
await GET(request, createMockRouteParams({}))
const chain = fromSpy.mock.results[0].value as { __calls: { method: string; args: unknown[] }[] }
const eqCalls = chain.__calls.filter((c) => c.method === 'eq')
// company_id and currency
expect(eqCalls).toEqual(
expect.arrayContaining([
{ method: 'eq', args: ['company_id', 'company-1'] },
{ method: 'eq', args: ['currency', 'SEK'] },
])
)
expect(chain.__calls).toEqual(
expect.arrayContaining([
{ method: 'gte', args: ['date', '2024-01-01'] },
{ method: 'lte', args: ['date', '2024-12-31'] },
])
)
})
it('returns 500 when the query errors', async () => {
enqueue({ data: null, error: { message: 'boom' } })
const request = createMockRequest('/api/transactions')
const response = await GET(request, createMockRouteParams({}))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect(body.error).toBe('Något gick fel. Försök igen.')
})
})