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
189 lines
8.2 KiB
TypeScript
189 lines
8.2 KiB
TypeScript
/**
|
|
* Unit tests for gnubok_ignore_transaction (issue #1661): registration,
|
|
* scope/risk/catalog wiring, the booked-row refusal through the shared core,
|
|
* and staging (dry run + persisted). Executor-side coverage
|
|
* (commitIgnoreTransaction) lives in
|
|
* lib/pending-operations/__tests__/ignore-transaction-executor.test.ts.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import { tools, isDefaultCatalogTool } from '../server'
|
|
import { RECOMMENDED_WORKFLOW_LOADOUTS } from '../recommended-tools'
|
|
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
|
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
|
|
|
|
const tool = tools.find((t) => t.name === 'gnubok_ignore_transaction')!
|
|
|
|
const TX_ID = '00000000-0000-4000-8000-0000000000aa'
|
|
const TX_ROW = {
|
|
id: TX_ID,
|
|
description: 'SWISH DUBBLETT',
|
|
merchant_name: null,
|
|
amount: -250,
|
|
currency: 'SEK',
|
|
date: '2025-11-15',
|
|
is_ignored: false,
|
|
}
|
|
const CORE_ROW = { id: TX_ID, journal_entry_id: null, is_ignored: false }
|
|
|
|
const noopSupabase = { from: vi.fn() } as never
|
|
|
|
/** Tool fetch + shared-core fetch + the three junction lookups. */
|
|
function enqueueUnbooked(
|
|
enqueue: (r: { data?: unknown; error?: unknown }) => void,
|
|
overrides: { tool?: Record<string, unknown>; core?: Record<string, unknown> } = {},
|
|
) {
|
|
enqueue({ data: { ...TX_ROW, ...overrides.tool } })
|
|
enqueue({ data: { ...CORE_ROW, ...overrides.core } })
|
|
enqueue({ data: [] }) // transaction_voucher_links
|
|
enqueue({ data: [] }) // invoice_payments
|
|
enqueue({ data: [] }) // supplier_invoice_payments
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('gnubok_ignore_transaction: registration', () => {
|
|
it('is registered in the DEFAULT catalog (a search-only write is uncallable on Claude.ai)', () => {
|
|
expect(tool).toBeDefined()
|
|
expect(isDefaultCatalogTool(tool)).toBe(true)
|
|
expect((tool.inputSchema as { additionalProperties?: boolean }).additionalProperties).toBe(false)
|
|
expect((tool.inputSchema as { required?: string[] }).required).toEqual(['transaction_id'])
|
|
const out = tool.outputSchema as { properties?: Record<string, unknown>; required?: string[] }
|
|
expect(out?.required).toContain('staged')
|
|
expect(tool.description).toMatch(/TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED/)
|
|
})
|
|
|
|
it('is mapped to transactions:write and the low risk tier', () => {
|
|
expect(TOOL_SCOPE_MAP.gnubok_ignore_transaction).toBe('transactions:write')
|
|
expect(OPERATION_RISK_TIERS.ignore_transaction).toBe('low')
|
|
})
|
|
|
|
it('is part of the categorize, close-period and reconcile loadouts', () => {
|
|
const byWorkflow = new Map(RECOMMENDED_WORKFLOW_LOADOUTS.map((l) => [l.workflow, l.tools]))
|
|
for (const workflow of ['categorize_month', 'close_period', 'reconcile_month']) {
|
|
expect(byWorkflow.get(workflow), workflow).toContain('gnubok_ignore_transaction')
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('gnubok_ignore_transaction: validation gates', () => {
|
|
it('rejects a missing transaction_id before any DB call', async () => {
|
|
await expect(
|
|
tool.execute({ transaction_id: '' }, 'company-1', 'user-1', noopSupabase),
|
|
).rejects.toThrow(/transaction_id/)
|
|
expect((noopSupabase as { from: ReturnType<typeof vi.fn> }).from).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects when the transaction does not exist in this company', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: null })
|
|
await expect(
|
|
tool.execute({ transaction_id: TX_ID }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toThrow(/not found/i)
|
|
})
|
|
|
|
it('refuses a directly booked row with TX_IGNORE_ALREADY_BOOKED', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: TX_ROW })
|
|
enqueue({ data: { ...CORE_ROW, journal_entry_id: 'je-1' } })
|
|
await expect(
|
|
tool.execute({ transaction_id: TX_ID }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toThrow(/TX_IGNORE_ALREADY_BOOKED/)
|
|
expect(findCalls('pending_operations', 'insert')).toEqual([])
|
|
})
|
|
|
|
it('refuses a bulk-booked row anchored only through transaction_voucher_links', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: TX_ROW })
|
|
enqueue({ data: CORE_ROW })
|
|
enqueue({ data: [{ transaction_id: TX_ID }] }) // voucher links
|
|
enqueue({ data: [] })
|
|
enqueue({ data: [] })
|
|
await expect(
|
|
tool.execute({ transaction_id: TX_ID }, 'company-1', 'user-1', supabase as never),
|
|
).rejects.toThrow(/TX_IGNORE_ALREADY_BOOKED/)
|
|
expect(findCalls('pending_operations', 'insert')).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('gnubok_ignore_transaction: staging', () => {
|
|
it('dry run previews the flip, marks period lock as not applicable, and writes nothing', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueueUnbooked(enqueue)
|
|
enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings
|
|
enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods
|
|
|
|
const result = (await tool.execute(
|
|
{ transaction_id: TX_ID, dry_run: true },
|
|
'company-1', 'user-1', supabase as never, { type: 'user' },
|
|
)) as { staged: boolean; dry_run?: boolean; risk_level: string; preview: Record<string, unknown> }
|
|
|
|
expect(result.staged).toBe(false)
|
|
expect(result.dry_run).toBe(true)
|
|
expect(result.risk_level).toBe('low')
|
|
expect(result.preview).toMatchObject({
|
|
transaction_id: TX_ID,
|
|
transaction_description: 'SWISH DUBBLETT',
|
|
amount: -250,
|
|
currently_ignored: false,
|
|
will_be_ignored: true,
|
|
already_in_state: false,
|
|
writes_verifikat: false,
|
|
period_lock_applies: false,
|
|
})
|
|
expect(findCalls('transactions', 'update')).toEqual([])
|
|
expect(findCalls('pending_operations', 'insert')).toEqual([])
|
|
})
|
|
|
|
it('stages an ignore_transaction operation with { transaction_id, ignored: true } params', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueueUnbooked(enqueue)
|
|
enqueue({ data: null }) // company_settings
|
|
enqueue({ data: null }) // fiscal_periods
|
|
enqueue({ data: { id: 'op-1' }, error: null }) // pending_operations insert
|
|
|
|
const result = (await tool.execute(
|
|
{ transaction_id: TX_ID },
|
|
'company-1', 'user-1', supabase as never, { type: 'user' },
|
|
)) as { staged: boolean; operation_id?: string; next?: { tool: string } }
|
|
|
|
expect(result.staged).toBe(true)
|
|
expect(result.operation_id).toBe('op-1')
|
|
expect(result.next?.tool).toBe('gnubok_list_uncategorized_transactions')
|
|
const inserts = findCalls('pending_operations', 'insert')
|
|
expect(inserts).toHaveLength(1)
|
|
expect(inserts[0][0]).toMatchObject({
|
|
operation_type: 'ignore_transaction',
|
|
title: 'Ignorera transaktion: SWISH DUBBLETT',
|
|
params: { transaction_id: TX_ID, ignored: true },
|
|
})
|
|
// Staging never flips the flag itself: that is the executor's job.
|
|
expect(findCalls('transactions', 'update')).toEqual([])
|
|
})
|
|
|
|
it('restore: true stages ignored: false without the booked check and points back at categorize', async () => {
|
|
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
|
|
enqueue({ data: { ...TX_ROW, is_ignored: true } }) // tool fetch
|
|
enqueue({ data: { ...CORE_ROW, is_ignored: true } }) // core fetch (no junction lookups on restore)
|
|
enqueue({ data: null }) // company_settings
|
|
enqueue({ data: null }) // fiscal_periods
|
|
enqueue({ data: { id: 'op-2' }, error: null }) // insert
|
|
|
|
const result = (await tool.execute(
|
|
{ transaction_id: TX_ID, restore: true },
|
|
'company-1', 'user-1', supabase as never, { type: 'user' },
|
|
)) as { staged: boolean; preview: Record<string, unknown>; next?: { tool: string } }
|
|
|
|
expect(result.staged).toBe(true)
|
|
expect(result.preview).toMatchObject({ currently_ignored: true, will_be_ignored: false })
|
|
expect(result.next?.tool).toBe('gnubok_categorize_transaction')
|
|
expect(findCalls('transaction_voucher_links', 'select')).toEqual([])
|
|
expect(findCalls('pending_operations', 'insert')[0][0]).toMatchObject({
|
|
title: 'Återställ ignorerad transaktion: SWISH DUBBLETT',
|
|
params: { transaction_id: TX_ID, ignored: false },
|
|
})
|
|
})
|
|
})
|