Files
accounted/lib/pending-operations/__tests__/executors.test.ts
T
Jakob WennbergandClaude Opus 4.7 b05f0e59b9 fix(mcp): correct fiscal_periods column ref + remove agent auto-commit (#394)
* fix(mcp): correct fiscal_periods column ref + remove agent auto-commit

The MCP `gnubok_list_fiscal_periods` tool selected a non-existent
`fiscal_periods.status` column, causing `column fiscal_periods.status
does not exist` errors when agents called it. Fixed by selecting the
real columns (`is_closed`, `locked_at`, `closed_at`,
`opening_balances_set`) and deriving `status` in code.

Also removes the agent auto-commit feature (settings card, gating logic,
DB columns, tests). In its current shape only `create_customer` was
auto-commitable, so the toggle changed nothing meaningful in practice
while implying a level of agent autonomy that wasn't actually granted.
The risk-tier infrastructure on `pending_operations` (actor model,
risk_level) is kept since it's still used by the /pending UI filters.

Migration `20260505120000_drop_agent_auto_commit.sql` drops:
  - pending_operations.auto_commit_eligible
  - pending_operations.auto_committed_at
  - company_settings.agent_auto_commit_enabled
  - company_settings.agent_auto_commit_max_amount

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

* fix(mcp): explicitly pick fields in gnubok_list_fiscal_periods response

Greptile flagged that `...p` spreads raw DB columns (`is_closed`,
`locked_at`, `closed_at`) into the tool response alongside the
derived `status`. Drop the spread for an explicit field list so the
tool contract is the derived status only — agents don't need to
reason about raw columns, and future SELECT additions won't silently
leak.

Also drops `closed_at` from the SELECT since it wasn't read.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:16:01 +02:00

356 lines
13 KiB
TypeScript

/**
* Unit tests for the executors added to bring every declared op type up to a
* callable state through `commitPendingOperation`. Tests run through the
* public dispatcher (executors are not exported individually) so the wiring
* is exercised alongside executor logic.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createQueuedMockSupabase, makeInvoice, makeFiscalPeriod } from '@/tests/helpers'
import type { PendingOperation } from '@/types'
vi.mock('@/lib/core/bookkeeping/period-service', async () => {
const actual = await vi.importActual<typeof import('@/lib/core/bookkeeping/period-service')>(
'@/lib/core/bookkeeping/period-service'
)
return {
...actual,
unlockPeriod: vi.fn(),
}
})
vi.mock('@/lib/import/sie-parser', () => ({
parseSIEFile: vi.fn(),
calculateFileHash: vi.fn(async () => 'mock-hash'),
}))
vi.mock('@/lib/import/sie-import', () => ({
executeSIEImport: vi.fn(),
}))
vi.mock('@/lib/bookkeeping/invoice-entries', async () => {
const actual =
await vi.importActual<typeof import('@/lib/bookkeeping/invoice-entries')>(
'@/lib/bookkeeping/invoice-entries'
)
return {
...actual,
createCreditNoteJournalEntry: vi.fn(),
}
})
import { commitPendingOperation } from '../commit'
import { unlockPeriod } from '@/lib/core/bookkeeping/period-service'
import { parseSIEFile } from '@/lib/import/sie-parser'
import { executeSIEImport } from '@/lib/import/sie-import'
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'create_customer',
status: 'pending',
title: 'test',
params: {},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'high',
created_at: '2026-05-03T00:00:00Z',
resolved_at: null,
updated_at: '2026-05-03T00:00:00Z',
...overrides,
} as PendingOperation
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
// ─── unlock_period ──────────────────────────────────────────────────
describe('commitPendingOperation: unlock_period', () => {
it('happy path: clears locked_at and returns committed', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', locked_at: null })
vi.mocked(unlockPeriod).mockResolvedValueOnce(period)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's pending_operations update
const op = makePendingOp({
operation_type: 'unlock_period',
params: { fiscal_period_id: 'fp-1' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ period_id: 'fp-1', locked_at: null })
expect(unlockPeriod).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'fp-1')
})
it('rejects when fiscal_period_id is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's reject update
const op = makePendingOp({ operation_type: 'unlock_period', params: {} })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(unlockPeriod).not.toHaveBeenCalled()
})
it('surfaces underlying service errors', async () => {
vi.mocked(unlockPeriod).mockRejectedValueOnce(new Error('Period is not locked'))
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's reject update on throw
const op = makePendingOp({
operation_type: 'unlock_period',
params: { fiscal_period_id: 'fp-1' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.error).toMatch(/not locked/)
})
})
// ─── import_sie ─────────────────────────────────────────────────────
describe('commitPendingOperation: import_sie', () => {
it('happy path: parses, imports, returns committed with summary', async () => {
vi.mocked(parseSIEFile).mockReturnValueOnce({} as never)
vi.mocked(executeSIEImport).mockResolvedValueOnce({
success: true,
importId: 'imp-1',
fiscalPeriodId: 'fp-1',
openingBalanceEntryId: 'ob-1',
journalEntriesCreated: 5,
journalEntryIds: ['je-1', 'je-2', 'je-3', 'je-4', 'je-5'],
errors: [],
warnings: ['minor warning'],
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's update
const op = makePendingOp({
operation_type: 'import_sie',
params: {
file_content: '#FLAGGA 0\n',
filename: 'test.sie',
mappings: [],
create_fiscal_period: true,
import_opening_balances: true,
import_transactions: true,
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
import_id: 'imp-1',
journal_entries_created: 5,
warnings: ['minor warning'],
})
expect(parseSIEFile).toHaveBeenCalledWith('#FLAGGA 0\n')
})
it('rejects when required params are missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's reject update
const op = makePendingOp({ operation_type: 'import_sie', params: { filename: 'x.sie' } })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(parseSIEFile).not.toHaveBeenCalled()
})
it('returns the executeSIEImport errors when success=false', async () => {
vi.mocked(parseSIEFile).mockReturnValueOnce({} as never)
vi.mocked(executeSIEImport).mockResolvedValueOnce({
success: false,
importId: null,
fiscalPeriodId: null,
openingBalanceEntryId: null,
journalEntriesCreated: 0,
journalEntryIds: [],
errors: ['duplicate import'],
warnings: [],
})
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's reject update
const op = makePendingOp({
operation_type: 'import_sie',
params: {
file_content: '#FLAGGA 0\n',
filename: 'test.sie',
mappings: [],
create_fiscal_period: true,
import_opening_balances: false,
import_transactions: true,
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.error).toMatch(/duplicate import/)
})
})
// ─── credit_invoice ─────────────────────────────────────────────────
describe('commitPendingOperation: credit_invoice', () => {
it('happy path (accrual): inserts negated credit note and books JE', async () => {
const original = makeInvoice({
id: 'inv-1',
invoice_number: 'F-2024001',
status: 'sent',
document_type: 'invoice',
subtotal: 1000,
vat_amount: 250,
total: 1250,
})
const originalWithItems = {
...original,
items: [
{ sort_order: 0, description: 'Service', quantity: 1, unit: 'st', unit_price: 1000, line_total: 1000, vat_rate: 25, vat_amount: 250 },
],
}
const creditNoteRow = { ...original, id: 'cn-1', invoice_number: 'KR-F-2024001' }
const completeCreditNote = { ...creditNoteRow, customer: { name: 'Acme AB' }, items: [] }
const { supabase, enqueue } = createQueuedMockSupabase()
// 0: CAS claim
enqueue({ data: { id: 'op-1' }, error: null })
// 1: fetch original with items
enqueue({ data: originalWithItems, error: null })
// 2: insert credit note
enqueue({ data: creditNoteRow, error: null })
// 3: insert items (await thenable)
enqueue({ data: null, error: null })
// 4: update original status='credited'
enqueue({ data: null, error: null })
// 5: re-fetch complete credit note with customer + items
enqueue({ data: completeCreditNote, error: null })
// 6: company_settings
enqueue({ data: { entity_type: 'aktiebolag', accounting_method: 'accrual' }, error: null })
// 7: update invoice with journal_entry_id
enqueue({ data: null, error: null })
// 8: dispatcher's pending_operations update
enqueue({ data: null, error: null })
vi.mocked(createCreditNoteJournalEntry).mockResolvedValueOnce({ id: 'je-1' } as never)
const op = makePendingOp({
operation_type: 'credit_invoice',
params: { invoice_id: 'inv-1', reason: 'Wrong amount' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ credit_note_id: 'cn-1', journal_entry_id: 'je-1' })
expect(createCreditNoteJournalEntry).toHaveBeenCalled()
})
it('skips JE on cash accounting', async () => {
const original = makeInvoice({ id: 'inv-1', status: 'paid', document_type: 'invoice' })
const originalWithItems = { ...original, items: [] }
const creditNoteRow = { ...original, id: 'cn-2', invoice_number: 'KR-F-2024001' }
const completeCreditNote = { ...creditNoteRow, customer: null, items: [] }
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: originalWithItems, error: null })
enqueue({ data: creditNoteRow, error: null })
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
enqueue({ data: completeCreditNote, error: null })
enqueue({ data: { entity_type: 'enskild_firma', accounting_method: 'cash' }, error: null })
// no JE update; go straight to dispatcher update
enqueue({ data: null, error: null })
const op = makePendingOp({
operation_type: 'credit_invoice',
params: { invoice_id: 'inv-1' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ credit_note_id: 'cn-2', journal_entry_id: null })
expect(createCreditNoteJournalEntry).not.toHaveBeenCalled()
})
it('auto-rejects when invoice is already credited (409)', async () => {
const original = makeInvoice({ id: 'inv-1', status: 'credited', document_type: 'invoice' })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { ...original, items: [] }, error: null })
// dispatcher auto-reject path also does an update
enqueue({ data: null, error: null })
const op = makePendingOp({
operation_type: 'credit_invoice',
params: { invoice_id: 'inv-1' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('rejected')
expect(result.auto_rejected).toBe(true)
expect(result.http_status).toBe(409)
})
it('rejects when invoice_id is missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's reject update
const op = makePendingOp({ operation_type: 'credit_invoice', params: {} })
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
})
it('rejects invoices with status outside sent/paid/overdue', async () => {
const original = makeInvoice({ id: 'inv-1', status: 'draft', document_type: 'invoice' })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { ...original, items: [] }, error: null })
enqueue({ data: null, error: null }) // dispatcher's reject update
const op = makePendingOp({
operation_type: 'credit_invoice',
params: { invoice_id: 'inv-1' },
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
})
})