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() 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 = {} // 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 // .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 } 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 required: string[] } expect(schema.properties.matched_supplier_id.type).toEqual(['string', 'null']) expect(schema.required).not.toContain('matched_supplier_id') } }) })