import { describe, it, expect, vi, beforeEach } from 'vitest'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { registerDescribeEntityTool } from '../sage-describe-entity.js'; import type { SoapClient } from '../../clients/soap-client.js'; import type { SoapResult } from '../../types/index.js'; function createMockSoapClient(overrides?: Partial): SoapClient { return { read: vi.fn(), query: vi.fn(), getDescription: vi.fn(), healthCheck: vi.fn(), ...overrides, } as unknown as SoapClient; } describe('registerDescribeEntityTool', () => { let server: McpServer; let registerToolSpy: ReturnType; beforeEach(() => { server = new McpServer({ name: 'test-server', version: '1.0.0' }); registerToolSpy = vi.spyOn(server, 'registerTool'); }); it('registers sage_describe_entity tool with correct metadata', () => { const soapClient = createMockSoapClient(); registerDescribeEntityTool(server, soapClient); expect(registerToolSpy).toHaveBeenCalledOnce(); const [name, config] = registerToolSpy.mock.calls[0]; expect(name).toBe('sage_describe_entity'); expect(config).toMatchObject({ description: expect.stringContaining('Get field definitions'), annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, }, }); }); describe('handler', () => { async function callHandler( soapClient: SoapClient, args: { publicName: string }, ): Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }> { registerDescribeEntityTool(server, soapClient); const handler = registerToolSpy.mock.calls[0][2] as ( args: { publicName: string }, ) => Promise<{ content: Array<{ type: string; text: string }>; isError?: boolean }>; return handler(args); } it('parses field definitions with @_ prefix attributes (fast-xml-parser format)', async () => { const soapClient = createMockSoapClient({ getDescription: vi.fn().mockResolvedValue({ status: 1, data: { FLD: [ { '@_NAM': 'NUM', '@_TYP': 'Char', '@_LEN': '20', '@_C_ENG': 'Invoice no.' }, { '@_NAM': 'BPRNUM', '@_TYP': 'Char', '@_LEN': '15', '@_C_ENG': 'Customer' }, { '@_NAM': 'AMTATI', '@_TYP': 'Decimal', '@_LEN': '17.2', '@_C_ENG': 'Amount incl. tax' }, ], }, messages: [], technicalInfos: [], } satisfies SoapResult), }); const result = await callHandler(soapClient, { publicName: 'SIH' }); expect(result.isError).toBeUndefined(); const parsed = JSON.parse(result.content[0].text); expect(parsed.publicName).toBe('SIH'); expect(parsed.fieldCount).toBe(3); expect(parsed.fields).toEqual([ { name: 'NUM', type: 'Char', length: '20', label: 'Invoice no.' }, { name: 'BPRNUM', type: 'Char', length: '15', label: 'Customer' }, { name: 'AMTATI', type: 'Decimal', length: '17.2', label: 'Amount incl. tax' }, ]); }); it('parses field definitions with plain attribute names (JSON format)', async () => { const soapClient = createMockSoapClient({ getDescription: vi.fn().mockResolvedValue({ status: 1, data: { FLD: [ { NAM: 'SOHNUM', TYP: 'Char', LEN: '20', C_ENG: 'Order no.' }, ], }, messages: [], technicalInfos: [], } satisfies SoapResult), }); const result = await callHandler(soapClient, { publicName: 'SOH' }); const parsed = JSON.parse(result.content[0].text); expect(parsed.fields).toEqual([ { name: 'SOHNUM', type: 'Char', length: '20', label: 'Order no.' }, ]); }); it('handles nested FLD under a root element', async () => { const soapClient = createMockSoapClient({ getDescription: vi.fn().mockResolvedValue({ status: 1, data: { OBJECT: { FLD: [ { '@_NAM': 'ITMREF', '@_TYP': 'Char', '@_LEN': '20', '@_C_ENG': 'Item' }, ], }, }, messages: [], technicalInfos: [], } satisfies SoapResult), }); const result = await callHandler(soapClient, { publicName: 'WITM' }); const parsed = JSON.parse(result.content[0].text); expect(parsed.fields).toEqual([ { name: 'ITMREF', type: 'Char', length: '20', label: 'Item' }, ]); }); it('handles single FLD object (not wrapped in array)', async () => { const soapClient = createMockSoapClient({ getDescription: vi.fn().mockResolvedValue({ status: 1, data: { FLD: { '@_NAM': 'NUM', '@_TYP': 'Char', '@_LEN': '20', '@_C_ENG': 'Number' }, }, messages: [], technicalInfos: [], } satisfies SoapResult), }); const result = await callHandler(soapClient, { publicName: 'SIH' }); const parsed = JSON.parse(result.content[0].text); expect(parsed.fieldCount).toBe(1); expect(parsed.fields[0].name).toBe('NUM'); }); it('returns empty fields for null data', async () => { const soapClient = createMockSoapClient({ getDescription: vi.fn().mockResolvedValue({ status: 1, data: null, messages: [], technicalInfos: [], } satisfies SoapResult), }); const result = await callHandler(soapClient, { publicName: 'SIH' }); const parsed = JSON.parse(result.content[0].text); expect(parsed.fields).toEqual([]); expect(parsed.fieldCount).toBe(0); }); it('filters out entries with empty names', async () => { const soapClient = createMockSoapClient({ getDescription: vi.fn().mockResolvedValue({ status: 1, data: { FLD: [ { '@_NAM': 'NUM', '@_TYP': 'Char', '@_LEN': '20', '@_C_ENG': 'Number' }, { '@_TYP': 'Char', '@_LEN': '10' }, ], }, messages: [], technicalInfos: [], } satisfies SoapResult), }); const result = await callHandler(soapClient, { publicName: 'SIH' }); const parsed = JSON.parse(result.content[0].text); expect(parsed.fieldCount).toBe(1); }); it('returns error on business error (status=0)', async () => { const soapClient = createMockSoapClient({ getDescription: vi.fn().mockResolvedValue({ status: 0, data: null, messages: [{ type: 3, message: 'Unknown publication' }], technicalInfos: [], } satisfies SoapResult), }); const result = await callHandler(soapClient, { publicName: 'INVALID' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Unknown publication'); }); it('handles transport errors with classified hints', async () => { const soapClient = createMockSoapClient({ getDescription: vi.fn().mockRejectedValue(new Error('unauthorized')), }); const result = await callHandler(soapClient, { publicName: 'SIH' }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('unauthorized'); expect(result.content[0].text).toContain('Hint'); }); }); });