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:
2026-03-10 17:04:32 +00:00
parent b87f1e327c
commit ef8d04e987
2 changed files with 530 additions and 0 deletions

View 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
View 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,
};
}
}
}