Mcp/bulk approval (#412)
* feat(pending-operations): implement bulk commit functionality with UI support * feat(pending-operations): add bulk action labels and warnings for confirmation dialogs * feat(pending-operations): enhance bulk commit functionality with rejection handling and summary updates
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
|
||||
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', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
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()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
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 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)
|
||||
expect(body.error).toBe('db connection lost')
|
||||
})
|
||||
|
||||
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: 'Operation not found' },
|
||||
])
|
||||
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: 'Already committed' },
|
||||
{
|
||||
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 }),
|
||||
{ userEmail: '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)
|
||||
expect(body.data.results).toEqual([
|
||||
{ id: VALID_ID_1, status: 'rejected', error: 'Resource already deleted' },
|
||||
])
|
||||
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)
|
||||
expect(body.data.results).toEqual([
|
||||
{ id: VALID_ID_1, status: 'committed' },
|
||||
{ id: VALID_ID_2, status: 'failed', error: 'boom' },
|
||||
{ id: VALID_ID_3, status: 'skipped', error: 'Already rejected' },
|
||||
{ id: VALID_ID_4, status: 'rejected', error: 'gone' },
|
||||
{ id: VALID_ID_5, status: 'failed', error: 'Operation not found' },
|
||||
])
|
||||
expect(body.data.summary).toEqual({
|
||||
total: 5,
|
||||
committed: 1,
|
||||
failed: 2,
|
||||
skipped: 1,
|
||||
rejected: 1,
|
||||
})
|
||||
expect(mockCommit).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { PendingOperationsBulkSchema } from '@/lib/api/schemas'
|
||||
import { commitPendingOperation } from '@/lib/pending-operations/commit'
|
||||
import type { PendingOperation } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface BulkCommitItemResult {
|
||||
id: string
|
||||
status: 'committed' | 'failed' | 'skipped' | 'rejected'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const writeCheck = await requireWritePermission(supabase, user.id)
|
||||
if (!writeCheck.ok) return writeCheck.response
|
||||
|
||||
const validated = await validateBody(request, PendingOperationsBulkSchema)
|
||||
if (!validated.success) return validated.response
|
||||
const { ids } = validated.data
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { data: ops, error: fetchError } = await supabase
|
||||
.from('pending_operations')
|
||||
.select('*')
|
||||
.in('id', ids)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (fetchError) {
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const opsById = new Map((ops ?? []).map((op) => [op.id, op as PendingOperation]))
|
||||
const results: BulkCommitItemResult[] = []
|
||||
|
||||
for (const id of ids) {
|
||||
const op = opsById.get(id)
|
||||
if (!op) {
|
||||
results.push({ id, status: 'failed', error: 'Operation not found' })
|
||||
continue
|
||||
}
|
||||
if (op.status !== 'pending') {
|
||||
results.push({ id, status: 'skipped', error: `Already ${op.status}` })
|
||||
continue
|
||||
}
|
||||
if (op.risk_level === 'high') {
|
||||
results.push({
|
||||
id,
|
||||
status: 'skipped',
|
||||
error: 'Hög risk — kräver individuellt godkännande',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await commitPendingOperation(supabase, user.id, companyId, op, {
|
||||
userEmail: user.email,
|
||||
})
|
||||
if (result.status === 'committed') {
|
||||
results.push({ id, status: 'committed' })
|
||||
} else if (result.status === 'rejected' && result.auto_rejected) {
|
||||
results.push({ id, status: 'rejected', error: result.error ?? 'Avvisad' })
|
||||
} else {
|
||||
results.push({ id, status: 'failed', error: result.error ?? 'Misslyckades' })
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
total: results.length,
|
||||
committed: results.filter((r) => r.status === 'committed').length,
|
||||
failed: results.filter((r) => r.status === 'failed').length,
|
||||
skipped: results.filter((r) => r.status === 'skipped').length,
|
||||
rejected: results.filter((r) => r.status === 'rejected').length,
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { results, summary } })
|
||||
}
|
||||
Reference in New Issue
Block a user