From ef8d04e9871f2a75febc858f9f99a288b91f3936 Mon Sep 17 00:00:00 2001 From: repi Date: Tue, 10 Mar 2026 17:04:32 +0000 Subject: [PATCH] feat(soap): add SOAP client with read/query/getDescription Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus --- src/clients/__tests__/soap-client.test.ts | 389 ++++++++++++++++++++++ src/clients/soap-client.ts | 141 ++++++++ 2 files changed, 530 insertions(+) create mode 100644 src/clients/__tests__/soap-client.test.ts create mode 100644 src/clients/soap-client.ts diff --git a/src/clients/__tests__/soap-client.test.ts b/src/clients/__tests__/soap-client.test.ts new file mode 100644 index 0000000..44cc9cd --- /dev/null +++ b/src/clients/__tests__/soap-client.test.ts @@ -0,0 +1,389 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { SageConfig } from '../../types/index.js'; + +const mockSetSecurity = vi.fn(); +const mockSetEndpoint = vi.fn(); +const mockReadAsync = vi.fn(); +const mockQueryAsync = vi.fn(); +const mockGetDescriptionAsync = vi.fn(); + +const mockClient = { + setSecurity: mockSetSecurity, + setEndpoint: mockSetEndpoint, + readAsync: mockReadAsync, + queryAsync: mockQueryAsync, + getDescriptionAsync: mockGetDescriptionAsync, +}; + +class MockBasicAuthSecurity { + user: string; + pass: string; + constructor(user: string, pass: string) { + this.user = user; + this.pass = pass; + } +} + +vi.mock('soap', () => ({ + default: { + createClientAsync: vi.fn().mockResolvedValue(mockClient), + BasicAuthSecurity: MockBasicAuthSecurity, + }, +})); + +const { SoapClient } = await import('../soap-client.js'); + +function makeConfig(overrides?: Partial): SageConfig { + return { + url: 'https://x3.example.com', + user: 'admin', + password: 'secret', + endpoint: 'SEED', + poolAlias: 'SEED', + language: 'ENG', + rejectUnauthorized: true, + ...overrides, + }; +} + +function makeSoapResponse( + returnKey: string, + overrides?: Record, +) { + return [ + { + [returnKey]: { + status: '1', + resultXml: '{"SINVOICE":{"NUM":"INV001"}}', + messages: undefined, + technicalInfos: undefined, + ...overrides, + }, + }, + ]; +} + +describe('SoapClient', () => { + let client: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + client = new SoapClient(makeConfig()); + }); + + describe('read', () => { + it('returns normalized SoapResult on success (status=1)', async () => { + mockReadAsync.mockResolvedValueOnce( + makeSoapResponse('readReturn'), + ); + + const result = await client.read({ + publicName: 'SIH', + key: { NUM: 'INV001' }, + }); + + expect(result).toEqual({ + status: 1, + data: { SINVOICE: { NUM: 'INV001' } }, + messages: [], + technicalInfos: [], + }); + }); + + it('returns status 0 with messages on business error', async () => { + mockReadAsync.mockResolvedValueOnce( + makeSoapResponse('readReturn', { + status: '0', + resultXml: '', + messages: [{ type: '3', message: 'Record not found' }], + }), + ); + + const result = await client.read({ + publicName: 'SIH', + key: { NUM: 'INVALID' }, + }); + + expect(result.status).toBe(0); + expect(result.data).toBeNull(); + expect(result.messages).toEqual([ + { type: 3, message: 'Record not found' }, + ]); + }); + + it('maps key object to array of key/value pairs', async () => { + mockReadAsync.mockResolvedValueOnce( + makeSoapResponse('readReturn'), + ); + + await client.read({ + publicName: 'SIH', + key: { NUM: 'INV001', SITE: 'MAIN' }, + }); + + const callArgs = mockReadAsync.mock.calls[0][0]; + expect(callArgs.objectKeys).toEqual([ + { key: 'NUM', value: 'INV001' }, + { key: 'SITE', value: 'MAIN' }, + ]); + }); + + it('passes correct callContext with empty codeUser/password', async () => { + mockReadAsync.mockResolvedValueOnce( + makeSoapResponse('readReturn'), + ); + + await client.read({ publicName: 'SIH', key: { NUM: 'X' } }); + + const callArgs = mockReadAsync.mock.calls[0][0]; + expect(callArgs.callContext).toEqual({ + codeLang: 'ENG', + codeUser: '', + password: '', + poolAlias: 'SEED', + poolId: '', + requestConfig: 'adxwss.optreturn=JSON&adxwss.beautify=false', + }); + }); + }); + + describe('query', () => { + it('returns results with default listSize 20', async () => { + mockQueryAsync.mockResolvedValueOnce( + makeSoapResponse('queryReturn', { + resultXml: '[{"NUM":"INV001"},{"NUM":"INV002"}]', + }), + ); + + const result = await client.query({ publicName: 'SIH' }); + + expect(result.status).toBe(1); + expect(result.data).toEqual([{ NUM: 'INV001' }, { NUM: 'INV002' }]); + + const callArgs = mockQueryAsync.mock.calls[0][0]; + expect(callArgs.listSize).toBe(20); + }); + + it('caps listSize at 200', async () => { + mockQueryAsync.mockResolvedValueOnce( + makeSoapResponse('queryReturn'), + ); + + await client.query({ publicName: 'SIH', listSize: 500 }); + + const callArgs = mockQueryAsync.mock.calls[0][0]; + expect(callArgs.listSize).toBe(200); + }); + + it('uses provided listSize when under cap', async () => { + mockQueryAsync.mockResolvedValueOnce( + makeSoapResponse('queryReturn'), + ); + + await client.query({ publicName: 'SIH', listSize: 50 }); + + const callArgs = mockQueryAsync.mock.calls[0][0]; + expect(callArgs.listSize).toBe(50); + }); + }); + + describe('getDescription', () => { + it('returns field definitions', async () => { + const descriptionData = { + fields: [ + { name: 'NUM', type: 'char', length: 20 }, + { name: 'DATE', type: 'date' }, + ], + }; + mockGetDescriptionAsync.mockResolvedValueOnce( + makeSoapResponse('getDescriptionReturn', { + resultXml: JSON.stringify(descriptionData), + }), + ); + + const result = await client.getDescription('SIH'); + + expect(result.status).toBe(1); + expect(result.data).toEqual(descriptionData); + }); + }); + + describe('healthCheck', () => { + it('returns connected status on success', async () => { + const result = await client.healthCheck(); + + expect(result.status).toBe('connected'); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it('returns error status on connection failure', async () => { + const soap = await import('soap'); + vi.mocked(soap.default.createClientAsync).mockRejectedValueOnce( + new Error('ECONNREFUSED'), + ); + const failClient = new SoapClient(makeConfig()); + + const result = await failClient.healthCheck(); + + expect(result.status).toBe('error: ECONNREFUSED'); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + }); + }); + + describe('resultXml parsing', () => { + it('parses JSON resultXml', async () => { + mockReadAsync.mockResolvedValueOnce( + makeSoapResponse('readReturn', { + resultXml: '{"invoice":"INV001"}', + }), + ); + + const result = await client.read({ + publicName: 'SIH', + key: { NUM: 'INV001' }, + }); + + expect(result.data).toEqual({ invoice: 'INV001' }); + }); + + it('falls back to XML parsing when JSON fails', async () => { + mockReadAsync.mockResolvedValueOnce( + makeSoapResponse('readReturn', { + resultXml: 'INV001', + }), + ); + + const result = await client.read({ + publicName: 'SIH', + key: { NUM: 'INV001' }, + }); + + expect(result.data).toBeTruthy(); + expect(typeof result.data).toBe('object'); + }); + + it('returns null for empty/missing resultXml', async () => { + mockReadAsync.mockResolvedValueOnce( + makeSoapResponse('readReturn', { resultXml: '' }), + ); + + const result = await client.read({ + publicName: 'SIH', + key: { NUM: 'INV001' }, + }); + + expect(result.data).toBeNull(); + }); + + it('returns raw string when both JSON and XML parsing fail', async () => { + const { XMLParser } = await import('fast-xml-parser'); + const originalParse = XMLParser.prototype.parse; + XMLParser.prototype.parse = () => { + throw new Error('parse error'); + }; + + const brokenClient = new SoapClient(makeConfig()); + mockReadAsync.mockResolvedValueOnce( + makeSoapResponse('readReturn', { + resultXml: 'not-json-not-xml {{{}}}', + }), + ); + + const result = await brokenClient.read({ + publicName: 'SIH', + key: { NUM: 'X' }, + }); + + expect(result.data).toBe('not-json-not-xml {{{}}}'); + XMLParser.prototype.parse = originalParse; + }); + }); + + describe('message normalization', () => { + it('defaults empty/undefined messages to empty array', async () => { + mockReadAsync.mockResolvedValueOnce( + makeSoapResponse('readReturn', { + messages: undefined, + technicalInfos: undefined, + }), + ); + + const result = await client.read({ + publicName: 'SIH', + key: { NUM: 'INV001' }, + }); + + expect(result.messages).toEqual([]); + expect(result.technicalInfos).toEqual([]); + }); + + it('normalizes single message object to array', async () => { + mockReadAsync.mockResolvedValueOnce( + makeSoapResponse('readReturn', { + messages: { type: '1', message: 'Info message' }, + }), + ); + + const result = await client.read({ + publicName: 'SIH', + key: { NUM: 'INV001' }, + }); + + expect(result.messages).toEqual([ + { type: 1, message: 'Info message' }, + ]); + }); + + it('normalizes array of messages with parseInt on type', async () => { + mockReadAsync.mockResolvedValueOnce( + makeSoapResponse('readReturn', { + messages: [ + { type: '1', message: 'Info' }, + { type: '3', message: 'Error' }, + ], + }), + ); + + const result = await client.read({ + publicName: 'SIH', + key: { NUM: 'INV001' }, + }); + + expect(result.messages).toEqual([ + { type: 1, message: 'Info' }, + { type: 3, message: 'Error' }, + ]); + }); + + it('normalizes technicalInfos', async () => { + mockReadAsync.mockResolvedValueOnce( + makeSoapResponse('readReturn', { + technicalInfos: [ + { type: 'DURATION', message: '150ms' }, + ], + }), + ); + + const result = await client.read({ + publicName: 'SIH', + key: { NUM: 'INV001' }, + }); + + expect(result.technicalInfos).toEqual([ + { type: 'DURATION', message: '150ms' }, + ]); + }); + }); + + describe('singleton client', () => { + it('reuses SOAP client across multiple calls', async () => { + const soap = await import('soap'); + mockReadAsync.mockResolvedValue(makeSoapResponse('readReturn')); + + await client.read({ publicName: 'SIH', key: { NUM: 'A' } }); + await client.read({ publicName: 'SIH', key: { NUM: 'B' } }); + + expect(soap.default.createClientAsync).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/clients/soap-client.ts b/src/clients/soap-client.ts new file mode 100644 index 0000000..05c74a5 --- /dev/null +++ b/src/clients/soap-client.ts @@ -0,0 +1,141 @@ +import soap from 'soap'; +import { XMLParser } from 'fast-xml-parser'; +import type { Client } from 'soap'; +import type { + SageConfig, + SoapReadOptions, + SoapQueryOptions, + SoapResult, + SoapMessage, + SoapTechInfo, +} from '../types/index.js'; + +export class SoapClient { + private config: SageConfig; + private client: Client | null = null; + private xmlParser: XMLParser; + + constructor(config: SageConfig) { + this.config = config; + this.xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + }); + } + + private async getClient(): Promise { + if (!this.client) { + const wsdlUrl = `${this.config.url}/soap-wsdl/syracuse/collaboration/syracuse/CAdxWebServiceXmlCC?wsdl`; + this.client = await soap.createClientAsync(wsdlUrl, {}); + this.client.setSecurity( + new soap.BasicAuthSecurity(this.config.user, this.config.password), + ); + const soapEndpoint = `${this.config.url}/soap-generic/syracuse/collaboration/syracuse/CAdxWebServiceXmlCC`; + this.client.setEndpoint(soapEndpoint); + } + return this.client; + } + + private buildCallContext() { + return { + codeLang: this.config.language, + codeUser: '', + password: '', + poolAlias: this.config.poolAlias, + poolId: '', + requestConfig: 'adxwss.optreturn=JSON&adxwss.beautify=false', + }; + } + + // SOAP caveat: status returns as STRING "1", empty arrays as undefined + private normalizeResult(raw: unknown): SoapResult { + const r = raw as Record | undefined; + return { + status: parseInt(String(r?.status ?? '0'), 10), + data: this.parseResultXml(r?.resultXml), + messages: this.normalizeMessages(r?.messages), + technicalInfos: this.normalizeTechInfos(r?.technicalInfos), + }; + } + + private parseResultXml(resultXml: unknown): unknown { + if (!resultXml || typeof resultXml !== 'string') return null; + try { + return JSON.parse(resultXml); + } catch { + try { + return this.xmlParser.parse(resultXml); + } catch { + return resultXml; + } + } + } + + private normalizeMessages(raw: unknown): SoapMessage[] { + if (!raw) return []; + const arr = Array.isArray(raw) ? raw : [raw]; + return arr.map((m: Record) => ({ + type: parseInt(String(m?.type ?? '0'), 10), + message: String(m?.message ?? ''), + })); + } + + private normalizeTechInfos(raw: unknown): SoapTechInfo[] { + if (!raw) return []; + const arr = Array.isArray(raw) ? raw : [raw]; + return arr.map((t: Record) => ({ + type: String(t?.type ?? ''), + message: String(t?.message ?? ''), + })); + } + + async read(options: SoapReadOptions): Promise { + const client = await this.getClient(); + const [response] = await client.readAsync({ + callContext: this.buildCallContext(), + publicName: options.publicName, + objectKeys: Object.entries(options.key).map(([key, value]) => ({ + key, + value, + })), + }); + const res = response as Record; + return this.normalizeResult(res?.readReturn ?? res); + } + + async query(options: SoapQueryOptions): Promise { + const client = await this.getClient(); + const listSize = Math.min(options.listSize || 20, 200); + const [response] = await client.queryAsync({ + callContext: this.buildCallContext(), + publicName: options.publicName, + objectKeys: [], + listSize, + }); + const res = response as Record; + return this.normalizeResult(res?.queryReturn ?? res); + } + + async getDescription(publicName: string): Promise { + const client = await this.getClient(); + const [response] = await client.getDescriptionAsync({ + callContext: this.buildCallContext(), + publicName, + }); + const res = response as Record; + return this.normalizeResult(res?.getDescriptionReturn ?? res); + } + + async healthCheck(): Promise<{ status: string; latencyMs: number }> { + const start = Date.now(); + try { + await this.getClient(); + return { status: 'connected', latencyMs: Date.now() - start }; + } catch (error) { + return { + status: `error: ${error instanceof Error ? error.message : String(error)}`, + latencyMs: Date.now() - start, + }; + } + } +}