Files
accounted/lib/pending-operations/__tests__/ignore-transaction-executor.test.ts
T
Jakob Wennberg dc07ca8872 feat(transactions): steer private marking in locked periods to ignore, with v1 and MCP ignore verbs (#1661) (#2031)
Decision (option a): a private marking stays a real booking (eget uttag/insattning), so it remains blocked in a locked or closed period; the legal escape for rows that are not affarshandelser is ignore. Private + locked now returns TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED with remediation naming the ignore paths instead of a bare PERIOD_LOCKED, on all four categorize surfaces and the bulk driver. New v1 POST/DELETE /transactions/{id}/ignore (isTransactionBooked-based 409, idempotent) and a staged MCP gnubok_ignore_transaction (+ accounted_ alias, search visibility to respect the tools/list payload ceiling) with operation_type ignore_transaction; the CHECK pair 20260831070000/070001 rebuilds the constraint from main's newest list plus the new value. Dashboard toast gains an Ignorera i stallet action. Closes #1661
2026-08-31 08:39:04 +01:00

147 lines
5.9 KiB
TypeScript

/**
* Executor tests for the staged ignore_transaction operation (issue #1661):
* commitIgnoreTransaction is private to commit.ts and reached through
* commitPendingOperation (same pattern as account-and-note-executors.test.ts).
* Staging-side coverage lives in
* extensions/general/mcp-server/__tests__/ignore-transaction.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import type { PendingOperation } from '@/types'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
import { commitPendingOperation } from '../commit'
const TX_ID = '00000000-0000-4000-8000-0000000000aa'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'ignore_transaction',
status: 'pending',
title: 'test',
params: { transaction_id: TX_ID, ignored: true },
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'low',
created_at: '2026-08-29T00:00:00Z',
resolved_at: null,
updated_at: '2026-08-29T00:00:00Z',
...overrides,
} as PendingOperation
}
function enqueueNoAnchors(enqueue: (r: { data?: unknown; error?: unknown }) => void) {
enqueue({ data: [] }) // transaction_voucher_links
enqueue({ data: [] }) // invoice_payments
enqueue({ data: [] }) // supplier_invoice_payments
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: ignore_transaction', () => {
it('happy path: flips is_ignored on an unbooked row and returns committed', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { id: TX_ID, journal_entry_id: null, is_ignored: false } }) // core fetch
enqueueNoAnchors(enqueue)
enqueue({ data: null }) // update
enqueue({ data: null }) // finalize
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ transaction_id: TX_ID, is_ignored: true, changed: true })
expect(findCalls('transactions', 'update')).toEqual([[{ is_ignored: true }]])
})
it('is idempotent: an already-ignored row commits with changed=false and no write', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { id: TX_ID, journal_entry_id: null, is_ignored: true } }) // core fetch
enqueueNoAnchors(enqueue)
enqueue({ data: null }) // finalize
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ is_ignored: true, changed: false })
expect(findCalls('transactions', 'update')).toEqual([])
})
it('refuses a row that got booked between staging and approval (TX_IGNORE_ALREADY_BOOKED)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { id: TX_ID, journal_entry_id: 'je-1', is_ignored: false } }) // core fetch: booked now
enqueue({ data: null }) // dispatcher reject/finalize update
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
expect(result.status).not.toBe('committed')
expect(result.code).toBe('TX_IGNORE_ALREADY_BOOKED')
expect(result.error).toContain('redan bokförd')
expect(findCalls('transactions', 'update')).toEqual([])
})
it('refuses a junction-anchored row (journal_entry_id NULL, voucher link present)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { id: TX_ID, journal_entry_id: null, is_ignored: false } })
enqueue({ data: [{ transaction_id: TX_ID }] }) // transaction_voucher_links
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: null }) // finalize
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
expect(result.status).not.toBe('committed')
expect(result.code).toBe('TX_IGNORE_ALREADY_BOOKED')
expect(findCalls('transactions', 'update')).toEqual([])
})
it('restore (ignored: false) clears the flag', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { id: TX_ID, journal_entry_id: null, is_ignored: true } }) // core fetch
enqueue({ data: null }) // update
enqueue({ data: null }) // finalize
const result = await commitPendingOperation(
supabase as never, 'user-1', 'company-1',
makePendingOp({ params: { transaction_id: TX_ID, ignored: false } }),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ is_ignored: false, changed: true })
expect(findCalls('transactions', 'update')).toEqual([[{ is_ignored: false }]])
})
it('rejects tampered params (non-UUID transaction_id) at the commit boundary', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: null }) // finalize
const result = await commitPendingOperation(
supabase as never, 'user-1', 'company-1',
makePendingOp({ params: { transaction_id: 'not-a-uuid', ignored: true } }),
)
expect(result.status).not.toBe('committed')
expect(result.error).toMatch(/transaction_id/)
expect(findCalls('transactions', 'select')).toEqual([])
})
})