Files
accounted/extensions/general/mcp-server/__tests__/bulk-book-inbox-dimensions.test.ts
T
Jakob WennbergandClaude Fable 5 80a14ddfd2 feat(mcp): dimension parity for the write/read tool edges (#1274)
Closes the MCP dimension gaps found in the 2026-07-28 audit:

- gnubok_bulk_book_inbox_items accepts a shared dimensions bag through
  all three layers (tool schema + BulkBookInboxSchema + categorize-core
  BulkBookInboxInput), resolve-don't-select with echoed resolutions; the
  web inbox bulk-book route and the pending-op executor inherit it via
  the shared schema.
- gnubok_create_employee / gnubok_update_employee accept
  default_dimensions (names resolve to codes; {} clears on update).
  The command layer already persisted the field: only the MCP boundary
  blocked it, leaving payroll tagging dashboard-only.
- gnubok_query_journal: dimensions bag filter (jsonb containment via
  the GIN index, covers custom dims the legacy project/cost_center
  filters cannot) + include_dimensions to return each line's bag.
  The wide full-match fetch stays dims-free unless something needs it.
- gnubok_list_invoices / gnubok_list_supplier_invoices return
  default_dimensions (agents could set invoice bags but never read
  them back).
- Discoverability: create_voucher, categorize_transaction,
  correct_entry, update_invoice descriptions now name dimensions;
  categorize_month and invoice_run loadouts include
  gnubok_list_dimensions. Trimmed new schema prose to stay under the
  tools/list payload budget.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 09:39:53 +02:00

118 lines
4.4 KiB
TypeScript

/**
* gnubok_bulk_book_inbox_items: shared dimensions bag.
*
* The bag resolves via the standard resolve-don't-select pass and is staged
* into the pending-operation params, from which the executor forwards it to
* bulkBookMatchedInboxItems (covered in
* lib/transactions/__tests__/categorize-core.bulk.test.ts).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { tools } from '../server'
import { eventBus } from '@/lib/events/bus'
const bulkBookInbox = tools.find((t) => t.name === 'gnubok_bulk_book_inbox_items')!
/** Per-table mock capturing insert payloads (same pattern as the payroll
* staged-tool tests). A single queued entry repeats for subsequent reads. */
function makeCapturingSupabase(byTable: Record<string, { data?: unknown; error?: unknown } | Array<{ data?: unknown; error?: unknown }>>) {
const queues = new Map<string, Array<{ data?: unknown; error?: unknown }>>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const inserts: Record<string, unknown[]> = {}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve({ count: null, ...next })
}
}
return (...callArgs: unknown[]) => {
if (prop === 'insert') {
;(inserts[table] ??= []).push(callArgs[0])
}
return buildChain(table)
}
},
}
return new Proxy({}, handler)
}
return { inserts, from: vi.fn((table: string) => buildChain(table)) }
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('gnubok_bulk_book_inbox_items: dimensions', () => {
it('stages the resolved bag in params and echoes it in the preview', async () => {
const supabaseMock = makeCapturingSupabase({
// Resolver: dimensions disabled → free-text passthrough. Also serves
// the period-status read at staging time.
company_settings: { data: { dimensions_enabled: false } },
invoice_inbox_items: {
data: [
{ id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null },
],
},
transactions: { data: [{ id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK' }] },
fiscal_periods: { data: null },
pending_operations: { data: { id: 'op-inbox-dims' }, error: null },
})
const result = (await bulkBookInbox.execute(
{
item_ids: ['i1'],
category: 'expense_software',
dimensions: { '6': 'P001' },
},
'company-1',
'user-1',
supabaseMock as never,
{ type: 'user' },
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview.dimensions).toEqual({ '6': 'P001' })
const inserted = supabaseMock.inserts.pending_operations?.[0] as {
params: Record<string, unknown>
}
expect(inserted.params.dimensions).toEqual({ '6': 'P001' })
})
it('stages dimensions: null when no bag is supplied (persisted-optional contract)', async () => {
const supabaseMock = makeCapturingSupabase({
company_settings: { data: { dimensions_enabled: false } },
invoice_inbox_items: {
data: [
{ id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null },
],
},
transactions: { data: [{ id: 'tx-1', date: '2026-06-01', amount: -700, currency: 'SEK' }] },
fiscal_periods: { data: null },
pending_operations: { data: { id: 'op-inbox-nodims' }, error: null },
})
const result = (await bulkBookInbox.execute(
{ item_ids: ['i1'], category: 'expense_software' },
'company-1',
'user-1',
supabaseMock as never,
{ type: 'user' },
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview).not.toHaveProperty('dimensions')
const inserted = supabaseMock.inserts.pending_operations?.[0] as {
params: Record<string, unknown>
}
// BulkBookInboxSchema normalizes the persisted null back to undefined at
// commit time.
expect(inserted.params.dimensions).toBeNull()
})
})