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