Files
accounted/app/api/pending-operations/bulk-commit/__tests__/route.test.ts
T
Jakob Wennberg 8d2ff61599 feat(bookkeeping): agent attribution into the immutable ledger layer (P0-1) (#678)
* feat(bookkeeping): agent attribution into the immutable ledger layer

Close the three attribution gaps left after 20260618120001 (which made
commit_method record 'api_key' for MCP-relayed approvals):

- journal_entries gains nullable committed_actor_type/committed_actor_label,
  stamped by commit_journal_entry in the same draft->posted UPDATE that
  writes commit_method. The RPC gains p_actor_type/p_actor_label
  (DEFAULT NULL; prior signature dropped first to avoid PostgREST overload
  ambiguity, same technique as 20260421140000).
- write_audit_log now populates audit_log.actor_type/actor_label from
  transaction-local gnubok.actor_* GUCs set by the RPC (the established
  gnubok.allow_delete pattern). Unset GUCs COALESCE to 'user' — byte-
  identical to the column's previous effective DEFAULT for every
  pre-existing write path.
- commitPendingOperation accepts opts.actor and runs the entire executor
  inside an AsyncLocalStorage runWithActor() scope read by commitEntry(),
  so EVERY journal commit an operation makes is attributed — closing the
  documented "commitMethod only reaches create_voucher" gap. MCP approve
  passes the api_key actor + key label; web single/bulk approve pass the
  user + email.

Known limitation (documented): reverseEntry posts reversal vouchers via
direct PostgREST writes, not the commit RPC — reversals keep NULL
attribution until that path is RPC-ified (follow-up).

pg-real coverage: lib/bookkeeping/__tests__/commit-actor.pg.test.ts
(RPC param stamping, audit GUC read, transaction-locality, CHECK
rejection, immutability of the new columns, single-signature guard).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): split actor-context so client bundles never see node:async_hooks

CI core-only build failed: engine.ts is reachable from client component
bundles (invoices/[id] page), and the static node:async_hooks import in
actor-context.ts cannot be chunked for the browser. Split the module:

- actor-context.ts (isomorphic): CommitActor type + a storage registry +
  getActor(). In a client bundle the registry stays empty and getActor()
  returns undefined — identical to the server-side no-scope default.
- actor-context-node.ts (server-only): owns the AsyncLocalStorage, binds it
  into the registry on import, exports runWithActor(). Imported only by the
  approval paths (commit.ts), which are never client-reachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:05:48 +02:00

335 lines
10 KiB
TypeScript

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 }),
// 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)
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)
})
})