Files
accounted/extensions/general/mcp-server/__tests__/list-unmatched-documents.test.ts
T
Jonas Flodén f58b7a3602 fix(mcp): treat created_journal_entry_id as a terminal link in gnubok_list_unmatched_documents (#891)
Documents booked directly as a journal entry (e.g. via SIE import) no longer appear as unmatched. Aligns the tool with the terminal-link predicate already used by gnubok_list_inbox_items and lib/worklist, preventing agents from double-processing already-booked documents. Adds a regression test suite.
2026-07-06 09:27:51 +02:00

134 lines
5.0 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { tools } from '../server'
/**
* Regression guard for a reported bug: documents booked directly as a
* journal entry (created_journal_entry_id set, e.g. via SIE import) kept
* showing up in gnubok_list_unmatched_documents even though nothing was left
* to do with them, while gnubok_list_inbox_items(unprocessed_only) already
* considered them terminal-linked and correctly omitted them. The two tools
* disagreed because this query only excluded created_supplier_invoice_id and
* a transactions.document_id match, never created_journal_entry_id.
*
* lib/pending-operations/__tests__/inbox-link-status.pg.test.ts already
* documents the intended contract ("the link column alone drops the row out
* of the 'needs action' filter (the UI and list_unmatched_documents read
* it)"); this test locks the query actually doing that.
*/
const tool = tools.find((t) => t.name === 'gnubok_list_unmatched_documents')!
function makeRecordingChain(result: { data: unknown; error: unknown }) {
const calls: Array<{ method: string; args: unknown[] }> = []
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
return (...args: unknown[]) => {
calls.push({ method: String(prop), args })
return proxy
}
},
}
const proxy = new Proxy({}, handler)
return { proxy, calls }
}
describe('gnubok_list_unmatched_documents', () => {
it('is registered as a read-only paginated tool', () => {
expect(tool).toBeDefined()
expect(tool.annotations?.readOnlyHint).toBe(true)
const schema = tool.outputSchema as { properties: Record<string, unknown> }
expect(schema.properties.items).toBeDefined()
expect(schema.properties.count).toBeDefined()
})
it('describes journal entries as a terminal link, not just bank transactions and supplier invoices', () => {
expect(tool.description).toMatch(/journal entry/)
})
it('excludes inbox rows already booked as a journal entry, not just supplier invoices', async () => {
const inboxChain = makeRecordingChain({ data: [], error: null })
const fromMock = vi.fn().mockReturnValue(inboxChain.proxy)
const supabase = { from: fromMock }
const result = (await tool.execute({}, 'company-1', 'user-1', supabase as never)) as {
items: unknown[]
count: number
}
expect(fromMock).toHaveBeenCalledWith('invoice_inbox_items')
const isCalls = inboxChain.calls.filter((c) => c.method === 'is')
expect(isCalls).toContainEqual({ method: 'is', args: ['created_supplier_invoice_id', null] })
expect(isCalls).toContainEqual({ method: 'is', args: ['created_journal_entry_id', null] })
expect(result).toEqual({ items: [], count: 0 })
})
it('maps extracted_data fields and drops documents already pinned to a transaction', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: [
{
id: 'inbox-1',
document_id: 'doc-1',
source: 'upload',
email_from: null,
email_subject: null,
email_received_at: null,
created_at: '2026-06-01T00:00:00Z',
extracted_data: {
supplier: { name: 'DNB Finans', orgNumber: '5164060161' },
invoice: { currency: 'SEK', invoiceDate: '2025-08-05', paymentReference: '9581810307' },
totals: { total: 13428 },
},
},
{
id: 'inbox-2',
document_id: 'doc-2',
source: 'upload',
email_from: null,
email_subject: null,
email_received_at: null,
created_at: '2026-05-01T00:00:00Z',
extracted_data: null,
},
],
error: null,
})
// doc-2 is already pinned to a bank transaction; doc-1 is not.
enqueue({ data: [{ document_id: 'doc-2' }], error: null })
const result = (await tool.execute({ limit: 20 }, 'company-1', 'user-1', supabase as never)) as {
items: Array<{ inbox_item_id: string; vendor_name: string | null; amount: number | null }>
count: number
}
expect(result.count).toBe(1)
expect(result.items[0].inbox_item_id).toBe('inbox-1')
expect(result.items[0].vendor_name).toBe('DNB Finans')
expect(result.items[0].amount).toBe(13428)
})
it('returns an empty result when the inbox query has nothing pending', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [], error: null })
const result = (await tool.execute({}, 'company-1', 'user-1', supabase as never)) as {
items: unknown[]
count: number
}
expect(result).toEqual({ items: [], count: 0 })
})
it('throws on a database error', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'connection refused' } })
await expect(tool.execute({}, 'company-1', 'user-1', supabase as never)).rejects.toThrow(
/connection refused/,
)
})
})