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>
151 lines
5.4 KiB
TypeScript
151 lines
5.4 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { createQueuedMockSupabase } from '@/tests/helpers'
|
|
import { tools } from '../server'
|
|
|
|
/**
|
|
* The tool is a thin wrapper over the transactions_without_documents RPC:
|
|
* the bank-driven subset of the verifikat surface, keyed on the SAME document
|
|
* truth (document_attachments + waivers), never transactions.document_id.
|
|
* Predicate semantics are pinned by
|
|
* tests/pg/document-surfaces-unification.pg.test.ts; these tests cover the
|
|
* wrapper contract (envelope unwrap, pagination math, error paths).
|
|
*/
|
|
const tool = tools.find((t) => t.name === 'gnubok_list_transactions_without_documents')!
|
|
|
|
function envelope(transactions: unknown[], totalCount: number) {
|
|
return { data: { ok: true, total_count: totalCount, transactions }, error: null }
|
|
}
|
|
|
|
const row = (id: string, jeId: string) => ({
|
|
id,
|
|
transaction_id: id,
|
|
date: '2026-04-12',
|
|
description: 'HOTELL ANGLAIS',
|
|
amount: -1247,
|
|
currency: 'SEK',
|
|
merchant_name: null,
|
|
reference: null,
|
|
is_business: null,
|
|
category: null,
|
|
journal_entry_id: jeId,
|
|
})
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('gnubok_list_transactions_without_documents', () => {
|
|
it('is registered as a read-only paginated tool with qualified transaction_id', () => {
|
|
expect(tool).toBeDefined()
|
|
expect(tool.annotations?.readOnlyHint).toBe(true)
|
|
const schema = tool.outputSchema as { properties: Record<string, unknown> }
|
|
expect(schema.properties.transactions).toBeDefined()
|
|
expect(schema.properties.total_count).toBeDefined()
|
|
const items = (schema.properties.transactions as { items: { properties: Record<string, unknown> } })
|
|
.items
|
|
expect(items.properties.transaction_id).toBeDefined()
|
|
expect(items.properties.journal_entry_id).toBeDefined()
|
|
})
|
|
|
|
it('unwraps the RPC envelope and passes filters through', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue(envelope([row('t1', 'je-1')], 1))
|
|
|
|
const result = (await tool.execute(
|
|
{ limit: 20, since: '2026-01-01' },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
)) as {
|
|
transactions: Array<{ id: string; transaction_id: string; journal_entry_id: string }>
|
|
count: number
|
|
total_count: number
|
|
has_more: boolean
|
|
}
|
|
|
|
expect(supabase.rpc).toHaveBeenCalledWith('transactions_without_documents', {
|
|
p_company_id: 'company-1',
|
|
p_since: '2026-01-01',
|
|
p_limit: 20,
|
|
p_offset: 0,
|
|
})
|
|
expect(result.count).toBe(1)
|
|
expect(result.total_count).toBe(1)
|
|
expect(result.has_more).toBe(false)
|
|
expect(result.transactions[0].transaction_id).toBe('t1')
|
|
expect(result.transactions[0].journal_entry_id).toBe('je-1')
|
|
})
|
|
|
|
it('never echoes the column default "uncategorized" on rows that are booked by construction', async () => {
|
|
// transactions.category keeps its default when a row is booked through a
|
|
// manual voucher + link (link-journal-entry.ts never writes category), and
|
|
// gnubok_list_uncategorized_transactions uses the same word to mean "no
|
|
// journal entry yet". Feedback seq 288574: an agent read the label and
|
|
// tried to book an already-booked share-capital deposit (A-2, 1930/2081).
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue(envelope([
|
|
{ ...row('t1', 'je-1'), category: 'uncategorized' },
|
|
{ ...row('t2', 'je-2'), category: 'office_supplies' },
|
|
], 2))
|
|
|
|
const result = (await tool.execute({}, 'company-1', 'user-1', supabase as never)) as {
|
|
transactions: Array<{ transaction_id: string; category: string | null; journal_entry_id: string }>
|
|
}
|
|
|
|
expect(result.transactions[0]).toMatchObject({ transaction_id: 't1', category: null, journal_entry_id: 'je-1' })
|
|
expect(result.transactions[1]).toMatchObject({ transaction_id: 't2', category: 'office_supplies' })
|
|
})
|
|
|
|
it('returns empty result when nothing matches', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue(envelope([], 0))
|
|
|
|
const result = (await tool.execute({}, 'company-1', 'user-1', supabase as never)) as {
|
|
count: number
|
|
total_count: number
|
|
has_more: boolean
|
|
}
|
|
|
|
expect(result.count).toBe(0)
|
|
expect(result.total_count).toBe(0)
|
|
expect(result.has_more).toBe(false)
|
|
})
|
|
|
|
it('signals more pages with next_offset advancing by rows consumed', async () => {
|
|
const page = Array.from({ length: 20 }, (_, i) => row(`t${i}`, `je-${i}`))
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue(envelope(page, 45))
|
|
|
|
const result = (await tool.execute(
|
|
{ limit: 20 },
|
|
'company-1',
|
|
'user-1',
|
|
supabase as never,
|
|
)) as {
|
|
has_more: boolean
|
|
next_offset?: number
|
|
}
|
|
|
|
expect(result.has_more).toBe(true)
|
|
expect(result.next_offset).toBe(20)
|
|
})
|
|
|
|
it('throws on an RPC 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/,
|
|
)
|
|
})
|
|
|
|
it('throws on a not-ok envelope (tenant guard)', async () => {
|
|
const { supabase, enqueue } = createQueuedMockSupabase()
|
|
enqueue({ data: { ok: false, code: 'TRANSACTIONS_WITHOUT_DOCUMENTS_FORBIDDEN' }, error: null })
|
|
|
|
await expect(tool.execute({}, 'company-1', 'user-1', supabase as never)).rejects.toThrow(
|
|
/TRANSACTIONS_WITHOUT_DOCUMENTS_FORBIDDEN/,
|
|
)
|
|
})
|
|
})
|