Three MCP feedback reports (seq 265062, 288474, 288577; three companies) hit the same hole: link_document_to_voucher attaches the document to the verifikat but never stamps the inbox item it came from, and both inbox read surfaces derive "handled" from the inbox row's own link columns, never from document_attachments.journal_entry_id. So an attached document stayed "unprocessed" forever: agents re-saw it as missing underlag (one reporter paged 750 rows to find the ~15 real ones), and one user read the five leftover rows as duplicates and was about to delete the only copies of underlag sitting on posted verifikat. - commitLinkDocumentToVoucher and the bulk twin now stamp invoice_inbox_items.created_journal_entry_id, keyed on document_id, CAS on both link columns, 23505 tolerated (samlingsverifikat). Same shape as the create_voucher + inbox_item_id stamp; best-effort so inbox bookkeeping never rolls back a committed link. - gnubok_list_unmatched_documents returns file_name (same embed list_inbox_items uses) and the extraction's page coverage, so an agent can tell "no total on this document" from "we read 3 of 38 pages" and does not have to fetch each document to learn what it is (seq 265062, 288574). - gnubok_list_transactions_without_documents no longer echoes the column default "uncategorized" on rows that are booked by construction: list_uncategorized_transactions uses the same word for "no journal entry yet", and an agent read the label and tried to re-book an already-booked share-capital deposit (seq 288574). No tools/list payload change: the unmatched-documents item schema is untyped and the category field already allowed null. Claude-Session: https://claude.ai/code/session_013yw62FMXGSzo6icFDiBwP3 Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
175 lines
6.6 KiB
TypeScript
175 lines
6.6 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',
|
|
document_attachments: { file_name: 'V82_2025-08-05_DNB-Finans.pdf' },
|
|
extracted_data: {
|
|
supplier: { name: 'DNB Finans', orgNumber: '5164060161' },
|
|
invoice: { currency: 'SEK', invoiceDate: '2025-08-05', paymentReference: '9581810307' },
|
|
totals: { total: 13428 },
|
|
pages: { total: 38, analyzed: 3 },
|
|
},
|
|
},
|
|
{
|
|
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
|
|
file_name: string | null
|
|
pages: { total: number; analyzed: 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)
|
|
// The original file name and the page coverage of the extraction travel
|
|
// with the row (feedback seq 265062 / 288574): the agent must not have to
|
|
// fetch every document to learn what it is, and amount: null on a
|
|
// sliced 38-page PDF is not a fact about the file.
|
|
expect(result.items[0].file_name).toBe('V82_2025-08-05_DNB-Finans.pdf')
|
|
expect(result.items[0].pages).toEqual({ total: 38, analyzed: 3 })
|
|
})
|
|
|
|
it('tolerates the embed coming back as an array and a document with no page info', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({
|
|
data: [
|
|
{
|
|
id: 'inbox-3',
|
|
document_id: 'doc-3',
|
|
source: 'email',
|
|
email_from: 'a@b.se',
|
|
email_subject: 'Kvitto',
|
|
email_received_at: '2026-06-02T00:00:00Z',
|
|
created_at: '2026-06-02T00:00:00Z',
|
|
document_attachments: [{ file_name: 'kvitto.jpg' }],
|
|
extracted_data: { totals: { total: 99 } },
|
|
},
|
|
],
|
|
error: null,
|
|
})
|
|
enqueue({ data: [], error: null })
|
|
|
|
const result = (await tool.execute({ limit: 20 }, 'company-1', 'user-1', supabase as never)) as {
|
|
items: Array<{ file_name: string | null; pages: unknown }>
|
|
}
|
|
expect(result.items[0].file_name).toBe('kvitto.jpg')
|
|
expect(result.items[0].pages).toBeNull()
|
|
})
|
|
|
|
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/,
|
|
)
|
|
})
|
|
})
|