feat(soap): add SOAP client with read/query/getDescription
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
389
src/clients/__tests__/soap-client.test.ts
Normal file
389
src/clients/__tests__/soap-client.test.ts
Normal file
@@ -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>): 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<string, unknown>,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
[returnKey]: {
|
||||||
|
status: '1',
|
||||||
|
resultXml: '{"SINVOICE":{"NUM":"INV001"}}',
|
||||||
|
messages: undefined,
|
||||||
|
technicalInfos: undefined,
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SoapClient', () => {
|
||||||
|
let client: InstanceType<typeof SoapClient>;
|
||||||
|
|
||||||
|
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: '<SINVOICE><FLD NAME="NUM">INV001</FLD></SINVOICE>',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
141
src/clients/soap-client.ts
Normal file
141
src/clients/soap-client.ts
Normal file
@@ -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<Client> {
|
||||||
|
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<string, unknown> | 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<string, unknown>) => ({
|
||||||
|
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<string, unknown>) => ({
|
||||||
|
type: String(t?.type ?? ''),
|
||||||
|
message: String(t?.message ?? ''),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(options: SoapReadOptions): Promise<SoapResult> {
|
||||||
|
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<string, unknown>;
|
||||||
|
return this.normalizeResult(res?.readReturn ?? res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(options: SoapQueryOptions): Promise<SoapResult> {
|
||||||
|
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<string, unknown>;
|
||||||
|
return this.normalizeResult(res?.queryReturn ?? res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDescription(publicName: string): Promise<SoapResult> {
|
||||||
|
const client = await this.getClient();
|
||||||
|
const [response] = await client.getDescriptionAsync({
|
||||||
|
callContext: this.buildCallContext(),
|
||||||
|
publicName,
|
||||||
|
});
|
||||||
|
const res = response as Record<string, unknown>;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user