Files
accounted/app/api/pending-operations/bulk-commit/__tests__/route.test.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

365 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events/bus'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
const mockCommit = vi.fn()
vi.mock('@/lib/pending-operations/commit', () => ({
commitPendingOperation: (...args: unknown[]) => mockCommit(...args),
}))
import { POST } from '../route'
const VALID_ID_1 = '11111111-1111-4111-8111-111111111111'
const VALID_ID_2 = '22222222-2222-4222-8222-222222222222'
const VALID_ID_3 = '33333333-3333-4333-8333-333333333333'
const VALID_ID_4 = '44444444-4444-4444-8444-444444444444'
const VALID_ID_5 = '55555555-5555-4555-8555-555555555555'
function makeOp(overrides: Record<string, unknown> = {}) {
return {
id: VALID_ID_1,
company_id: 'company-1',
user_id: 'user-1',
operation_type: 'categorize_transaction',
status: 'pending',
risk_level: 'low',
title: 'Kategorisera test',
params: {},
preview_data: {},
...overrides,
}
}
describe('POST /api/pending-operations/bulk-commit', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 403 for a viewer without write permission', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1] },
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(403)
})
it('returns 400 when ids array is empty', async () => {
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [] },
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(mockCommit).not.toHaveBeenCalled()
})
it('returns 400 when ids contain non-UUID values', async () => {
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: ['not-a-uuid'] },
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(mockCommit).not.toHaveBeenCalled()
})
it('returns 400 when ids exceed 100 items', async () => {
const ids = Array.from({ length: 101 }, (_, i) => {
const hex = i.toString(16).padStart(4, '0')
return `${hex}${hex}${hex}${hex}-${hex}${hex}-4${hex.slice(1)}-8${hex.slice(1)}-${hex}${hex}${hex}${hex}${hex}${hex}`
})
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids },
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
expect(mockCommit).not.toHaveBeenCalled()
})
it('returns 500 when fetching pending operations fails', async () => {
enqueue({ data: null, error: { message: 'db connection lost' } })
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
// Raw Supabase messages never reach the response field (issue #337).
expect(body.error).toBe('Åtgärderna kunde inte hämtas. Försök igen.')
})
it('reports per-item not-found as failed without calling commit', async () => {
enqueue({ data: [] })
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string; error?: string }>
summary: { total: number; committed: number; failed: number; skipped: number; rejected: number }
}
}>(response)
expect(status).toBe(200)
expect(body.data.results).toEqual([
{ id: VALID_ID_1, status: 'failed', error: 'Åtgärden kunde inte hittas.' },
])
expect(body.data.summary).toEqual({
total: 1,
committed: 0,
failed: 1,
skipped: 0,
rejected: 0,
})
expect(mockCommit).not.toHaveBeenCalled()
})
it('skips non-pending operations and high-risk operations', async () => {
enqueue({
data: [
makeOp({ id: VALID_ID_1, status: 'committed' }),
makeOp({ id: VALID_ID_2, status: 'pending', risk_level: 'high' }),
],
})
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1, VALID_ID_2] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string; error?: string }>
summary: { total: number; committed: number; failed: number; skipped: number; rejected: number }
}
}>(response)
expect(status).toBe(200)
expect(body.data.results).toEqual([
{ id: VALID_ID_1, status: 'skipped', error: 'Redan hanterad (godkänd)' },
{
id: VALID_ID_2,
status: 'skipped',
error: 'Hög risk: kräver individuellt godkännande',
},
])
expect(body.data.summary).toEqual({
total: 2,
committed: 0,
failed: 0,
skipped: 2,
rejected: 0,
})
expect(mockCommit).not.toHaveBeenCalled()
})
it('commits pending operations and aggregates summary on the happy path', async () => {
enqueue({
data: [
makeOp({ id: VALID_ID_1 }),
makeOp({ id: VALID_ID_2 }),
],
})
mockCommit.mockResolvedValue({ status: 'committed', data: {} })
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1, VALID_ID_2] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string }>
summary: { total: number; committed: number; failed: number; skipped: number; rejected: number }
}
}>(response)
expect(status).toBe(200)
expect(body.data.results).toEqual([
{ id: VALID_ID_1, status: 'committed' },
{ id: VALID_ID_2, status: 'committed' },
])
expect(body.data.summary).toEqual({
total: 2,
committed: 2,
failed: 0,
skipped: 0,
rejected: 0,
})
expect(mockCommit).toHaveBeenCalledTimes(2)
expect(mockCommit).toHaveBeenCalledWith(
mockSupabase,
'user-1',
'company-1',
expect.objectContaining({ id: VALID_ID_1 }),
// commit_method must be 'bulk_accept' so any journal_entries created
// during bulk approval are tagged distinctly from single-approval ones
// (BFNAR 2013:2 behandlingshistorik). The actor option attributes the
// commits to the approving user (migration 20260619120000).
{
userEmail: 'test@test.se',
commitMethod: 'bulk_accept',
actor: { type: 'user', label: 'test@test.se' },
}
)
})
it('routes auto_rejected results into the rejected bucket', async () => {
enqueue({ data: [makeOp({ id: VALID_ID_1 })] })
mockCommit.mockResolvedValue({
status: 'rejected',
auto_rejected: true,
error: 'Resource already deleted',
http_status: 409,
})
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string; error?: string }>
summary: { total: number; committed: number; failed: number; skipped: number; rejected: number }
}
}>(response)
expect(status).toBe(200)
// English executor message → Swedish HTTP-409 fallback (issue #337).
expect(body.data.results).toEqual([
{ id: VALID_ID_1, status: 'rejected', error: 'En konflikt uppstod. Ladda om sidan och försök igen.' },
])
expect(body.data.summary).toEqual({
total: 1,
committed: 0,
failed: 0,
skipped: 0,
rejected: 1,
})
})
it('reports commit failures as failed and aggregates a mixed summary', async () => {
enqueue({
data: [
makeOp({ id: VALID_ID_1 }),
makeOp({ id: VALID_ID_2 }),
makeOp({ id: VALID_ID_3, status: 'rejected' }),
makeOp({ id: VALID_ID_4 }),
],
})
mockCommit
.mockResolvedValueOnce({ status: 'committed', data: {} })
.mockResolvedValueOnce({ status: 'failed', error: 'boom', http_status: 500 })
.mockResolvedValueOnce({
status: 'rejected',
auto_rejected: true,
error: 'gone',
http_status: 404,
})
const request = createMockRequest('/api/pending-operations/bulk-commit', {
method: 'POST',
body: { ids: [VALID_ID_1, VALID_ID_2, VALID_ID_3, VALID_ID_4, VALID_ID_5] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{
data: {
results: Array<{ id: string; status: string; error?: string }>
summary: { total: number; committed: number; failed: number; skipped: number; rejected: number }
}
}>(response)
expect(status).toBe(200)
// English executor strings map to the status-appropriate Swedish
// fallbacks; skip/not-found strings are now Swedish at the source (#337).
expect(body.data.results).toEqual([
{ id: VALID_ID_1, status: 'committed' },
{ id: VALID_ID_2, status: 'failed', error: 'Ett oväntat serverfel uppstod. Försök igen senare.' },
{ id: VALID_ID_3, status: 'skipped', error: 'Redan hanterad (avvisad)' },
{ id: VALID_ID_4, status: 'rejected', error: 'Resursen kunde inte hittas.' },
{ id: VALID_ID_5, status: 'failed', error: 'Åtgärden kunde inte hittas.' },
])
expect(body.data.summary).toEqual({
total: 5,
committed: 1,
failed: 2,
skipped: 1,
rejected: 1,
})
expect(mockCommit).toHaveBeenCalledTimes(3)
})
})