2860cd51b1
Two MCP-agent-reported bugs (feedback seq 254607, 261972; also reported by mail): - categorize_transaction validated vat_amount against the transaction- currency gross but posted the raw figure as SEK: a 15.87 USD override on a 79.34 USD Stripe payment booked 15.87 kr to 2611 instead of ~150.92 kr, and the entry still balanced so nothing could catch it. buildMappingResultFromCategory now resolves the same SEK value the line builder uses for the gross and books ALL VAT figures off it: the override (scaled by the settlement ratio), the auto-derived rate VAT, and reverse-charge fiktiv moms, which had the same defect for every foreign-currency transaction. SEK transactions are unchanged. The vat_amount schema now states the denomination; the override bound error names the currency. - complete_document_upload / upload_document declared matched_supplier_id as a bare string while unmatched uploads correctly return null, so strict clients failed every successful unmatched upload and tripped the caller's circuit breaker. The schema is now ['string', 'null'], matching the runtime. Catalog token ceiling 59.9K -> 59.95K per the documented ratchet protocol (prose trimmed to the floor first; headroom was ~19 tokens). Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
227 lines
8.2 KiB
TypeScript
227 lines
8.2 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { makeDocumentAttachment } from '@/tests/helpers'
|
|
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
|
import { MCP_TOOL_CAPABILITY_MAP } from '@/lib/entitlements/keys'
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
createPendingDocumentUpload: vi.fn(),
|
|
completePendingDocumentUpload: vi.fn(),
|
|
extractInvoiceFields: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@/lib/core/documents/document-service', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/core/documents/document-service')>()
|
|
return {
|
|
...actual,
|
|
createPendingDocumentUpload: mocks.createPendingDocumentUpload,
|
|
completePendingDocumentUpload: mocks.completePendingDocumentUpload,
|
|
}
|
|
})
|
|
|
|
vi.mock('@/extensions/general/invoice-inbox/lib/extract-invoice-fields', async (importOriginal) => {
|
|
const actual = await importOriginal<
|
|
typeof import('@/extensions/general/invoice-inbox/lib/extract-invoice-fields')
|
|
>()
|
|
return { ...actual, extractInvoiceFields: mocks.extractInvoiceFields }
|
|
})
|
|
|
|
import { tools } from '../server'
|
|
|
|
const companyId = '11111111-1111-4111-8111-111111111111'
|
|
const userId = '22222222-2222-4222-8222-222222222222'
|
|
const uploadId = '33333333-3333-4333-8333-333333333333'
|
|
|
|
function findTool(name: string) {
|
|
const tool = tools.find((candidate) => candidate.name === name)
|
|
if (!tool) throw new Error(`Tool not found: ${name}`)
|
|
return tool
|
|
}
|
|
|
|
function makeQueryBuilder(result: { data: unknown; error: unknown }) {
|
|
const builder: Record<string, unknown> = {}
|
|
// ilike/not/order/range serve the shared supplier matcher
|
|
// (lib/suppliers/match-supplier.ts): name lookup and the vat_number scan.
|
|
for (const method of ['select', 'eq', 'limit', 'insert', 'ilike', 'not', 'order']) {
|
|
builder[method] = vi.fn().mockReturnValue(builder)
|
|
}
|
|
builder.maybeSingle = vi.fn().mockResolvedValue(result)
|
|
builder.single = vi.fn().mockResolvedValue(result)
|
|
builder.range = vi.fn().mockResolvedValue({ data: [], error: null })
|
|
return builder
|
|
}
|
|
|
|
describe('MCP model-free document upload tools', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mocks.createPendingDocumentUpload.mockResolvedValue({
|
|
uploadId,
|
|
signedUrl: 'https://storage.example/upload?token=signed',
|
|
expiresAt: '2026-08-03T12:00:00.000Z',
|
|
})
|
|
mocks.completePendingDocumentUpload.mockResolvedValue({
|
|
document: makeDocumentAttachment({
|
|
id: uploadId,
|
|
user_id: userId,
|
|
company_id: companyId,
|
|
file_name: 'invoice.pdf',
|
|
mime_type: 'application/pdf',
|
|
}),
|
|
buffer: new TextEncoder().encode('%PDF-1.4\n%%EOF\n').buffer,
|
|
})
|
|
mocks.extractInvoiceFields.mockResolvedValue({
|
|
data: {
|
|
supplier: { name: 'Synthetic Supplier AB', orgNumber: null },
|
|
invoice: { number: 'INV-1' },
|
|
},
|
|
})
|
|
})
|
|
|
|
// Claude Desktop's sandbox only reaches the MCP host: a signed URL on
|
|
// <project>.supabase.co was refused there (2026-08-21), so the tool hands
|
|
// out the same-origin /api/storage proxy URL instead.
|
|
it('serves the upload URL from the app origin when the signed URL points at our Storage host', async () => {
|
|
vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'https://pwxtzglxptnnvjrpixpg.supabase.co')
|
|
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.accounted.se')
|
|
mocks.createPendingDocumentUpload.mockResolvedValue({
|
|
uploadId,
|
|
signedUrl:
|
|
'https://pwxtzglxptnnvjrpixpg.supabase.co/storage/v1/object/upload/sign/documents/co/user/pending/up/invoice.pdf?token=signed',
|
|
expiresAt: '2026-08-03T12:00:00.000Z',
|
|
})
|
|
|
|
const result = await findTool('gnubok_create_document_upload').execute(
|
|
{ file_name: 'invoice.pdf' },
|
|
companyId,
|
|
userId,
|
|
{} as never,
|
|
)
|
|
|
|
expect(result).toMatchObject({
|
|
upload_url:
|
|
'https://app.accounted.se/api/storage/upload/sign/documents/co/user/pending/up/invoice.pdf?token=signed',
|
|
})
|
|
})
|
|
|
|
it('returns an unauthenticated PUT URL without accepting file bytes', async () => {
|
|
const tool = findTool('gnubok_create_document_upload')
|
|
const result = await tool.execute(
|
|
{ file_name: 'invoice.pdf' },
|
|
companyId,
|
|
userId,
|
|
{} as never,
|
|
)
|
|
|
|
expect(mocks.createPendingDocumentUpload).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
companyId,
|
|
userId,
|
|
expect.stringMatching(/^[0-9a-f-]{36}$/),
|
|
'invoice.pdf',
|
|
)
|
|
expect(result).toEqual({
|
|
upload_id: uploadId,
|
|
upload_url: 'https://storage.example/upload?token=signed',
|
|
expires_at: '2026-08-03T12:00:00.000Z',
|
|
})
|
|
const schema = tool.inputSchema as { properties: Record<string, unknown> }
|
|
expect(schema.properties).not.toHaveProperty('file_content_base64')
|
|
})
|
|
|
|
it('completes the reserved upload and uses the upload UUID for both records', async () => {
|
|
const inboxInsert = makeQueryBuilder({ data: { id: uploadId, status: 'received' }, error: null })
|
|
const invoiceLookups = [
|
|
makeQueryBuilder({ data: null, error: null }),
|
|
makeQueryBuilder({ data: null, error: null }),
|
|
inboxInsert,
|
|
]
|
|
const supplier = makeQueryBuilder({ data: null, error: null })
|
|
const from = vi.fn((table: string) => {
|
|
if (table === 'invoice_inbox_items') return invoiceLookups.shift()
|
|
if (table === 'suppliers') return supplier
|
|
throw new Error(`Unexpected table: ${table}`)
|
|
})
|
|
|
|
const result = await findTool('gnubok_complete_document_upload').execute(
|
|
{ upload_id: uploadId, file_name: 'invoice.pdf', mime_type: 'application/pdf' },
|
|
companyId,
|
|
userId,
|
|
{ from } as never,
|
|
)
|
|
|
|
expect(mocks.completePendingDocumentUpload).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
companyId,
|
|
userId,
|
|
uploadId,
|
|
'invoice.pdf',
|
|
'application/pdf',
|
|
undefined,
|
|
// The inbox item created right after owns extraction; the
|
|
// document-extraction extension must yield on the uploaded event.
|
|
{ extractionOwner: 'invoice-inbox' },
|
|
)
|
|
expect(inboxInsert.insert).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: uploadId, document_id: uploadId }),
|
|
)
|
|
expect(result).toMatchObject({
|
|
document_id: uploadId,
|
|
inbox_item_id: uploadId,
|
|
status: 'received',
|
|
})
|
|
expect(mocks.extractInvoiceFields).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('returns an already completed inbox item without downloading or extracting again', async () => {
|
|
const existing = makeQueryBuilder({
|
|
data: {
|
|
id: uploadId,
|
|
document_id: uploadId,
|
|
status: 'received',
|
|
extracted_data: { invoice: { number: 'INV-1' } },
|
|
matched_supplier_id: null,
|
|
},
|
|
error: null,
|
|
})
|
|
const result = await findTool('gnubok_complete_document_upload').execute(
|
|
{ upload_id: uploadId, file_name: 'invoice.pdf', mime_type: 'application/pdf' },
|
|
companyId,
|
|
userId,
|
|
{ from: vi.fn().mockReturnValue(existing) } as never,
|
|
)
|
|
|
|
expect(result).toMatchObject({ document_id: uploadId, inbox_item_id: uploadId })
|
|
expect(mocks.completePendingDocumentUpload).not.toHaveBeenCalled()
|
|
expect(mocks.extractInvoiceFields).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps scope and AI capability gates aligned across all upload paths', () => {
|
|
for (const name of [
|
|
'gnubok_create_document_upload',
|
|
'gnubok_complete_document_upload',
|
|
'gnubok_upload_document',
|
|
]) {
|
|
expect(TOOL_SCOPE_MAP[name]).toBe('transactions:write')
|
|
expect(MCP_TOOL_CAPABILITY_MAP[name]).toBe('ai')
|
|
}
|
|
})
|
|
|
|
it('declares matched_supplier_id nullable: unmatched suppliers return null (MCP feedback seq 261972)', () => {
|
|
// Strict clients validate structuredContent against outputSchema; a bare
|
|
// { type: 'string' } turned every unmatched upload into a client-side
|
|
// validation error (and tripped the caller's circuit breaker) even though
|
|
// the upload itself succeeded.
|
|
for (const name of ['gnubok_complete_document_upload', 'gnubok_upload_document']) {
|
|
const schema = findTool(name).outputSchema as {
|
|
properties: Record<string, { type: unknown }>
|
|
required: string[]
|
|
}
|
|
expect(schema.properties.matched_supplier_id.type).toEqual(['string', 'null'])
|
|
expect(schema.required).not.toContain('matched_supplier_id')
|
|
}
|
|
})
|
|
})
|