diff --git a/extensions/general/mcp-server/__tests__/list-unmatched-documents.test.ts b/extensions/general/mcp-server/__tests__/list-unmatched-documents.test.ts new file mode 100644 index 00000000..4fbe8162 --- /dev/null +++ b/extensions/general/mcp-server/__tests__/list-unmatched-documents.test.ts @@ -0,0 +1,133 @@ +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 = { + 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 } + 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/, + ) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 003deabf..18c8dab0 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -7890,7 +7890,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_list_unmatched_documents', title: 'List Unmatched Documents', - description: 'List inbox documents not yet attached to any bank transaction or supplier invoice. Returns vendor/amount/currency/date hints. Amount is in the invoice currency; FX-normalise before comparing to transactions.amount.', + description: 'List inbox documents not yet attached to any bank transaction, supplier invoice, or journal entry. Returns vendor/amount/currency/date hints. Amount is in the invoice currency; FX-normalise before comparing to transactions.amount.', inputSchema: { type: 'object', additionalProperties: false, @@ -7933,8 +7933,10 @@ export const tools: McpTool[] = [ } } - // Pull recent inbox items with a document, no supplier invoice yet, then - // filter out those whose document is already pinned to a transaction. + // Pull recent inbox items with a document, no supplier invoice and no + // direct journal entry yet (both are terminal links per the same + // "processed" semantics gnubok_list_inbox_items uses), then filter out + // those whose document is already pinned to a transaction. // Two-step query because PostgREST doesn't expose anti-joins. const fetchSize = limit * 2 let inboxQuery = supabase @@ -7943,6 +7945,7 @@ export const tools: McpTool[] = [ .eq('company_id', companyId) .not('document_id', 'is', null) .is('created_supplier_invoice_id', null) + .is('created_journal_entry_id', null) .order('created_at', { ascending: false }) .order('id', { ascending: false }) .limit(fetchSize)