diff --git a/src/clients/__tests__/rest-client.test.ts b/src/clients/__tests__/rest-client.test.ts new file mode 100644 index 0000000..fa18af5 --- /dev/null +++ b/src/clients/__tests__/rest-client.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { RestClient } from '../rest-client.js'; +import type { SageConfig } from '../../types/index.js'; + +const mockConfig: SageConfig = { + url: 'https://x3.example.com:8124', + user: 'admin', + password: 'secret123', + endpoint: 'SEED', + poolAlias: 'SEED', + language: 'ENG', + rejectUnauthorized: true, +}; + +function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve(body), + } as Response; +} + +describe('RestClient', () => { + let client: RestClient; + let fetchSpy: ReturnType; + + beforeEach(() => { + client = new RestClient(mockConfig); + fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({})); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('auth header', () => { + it('sends correct Basic auth on every request', async () => { + await client.healthCheck(); + + const [, init] = fetchSpy.mock.calls[0]; + const headers = init?.headers as Record; + const decoded = Buffer.from(headers['Authorization'].replace('Basic ', ''), 'base64').toString(); + expect(decoded).toBe('admin:secret123'); + }); + }); + + describe('query', () => { + it('returns paginated results with records and pagination', async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + $resources: [{ ITMREF: 'A001' }, { ITMREF: 'A002' }], + $links: { $next: { $url: 'https://x3.example.com:8124/next-page' } }, + }), + ); + + const result = await client.query({ entity: 'ITMMASTER' }); + + expect(result.records).toHaveLength(2); + expect(result.pagination.returned).toBe(2); + expect(result.pagination.hasMore).toBe(true); + expect(result.pagination.nextUrl).toBe('https://x3.example.com:8124/next-page'); + }); + + it('uses nextUrl directly for cursor-based pagination', async () => { + const nextUrl = 'https://x3.example.com:8124/api1/x3/erp/SEED/ITMMASTER?token=abc123'; + fetchSpy.mockResolvedValueOnce(jsonResponse({ $resources: [{ ITMREF: 'B001' }] })); + + await client.query({ entity: 'ITMMASTER', nextUrl }); + + expect(fetchSpy).toHaveBeenCalledWith(nextUrl, expect.objectContaining({ method: 'GET' })); + }); + + it('caps count at 200 when higher value requested', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ $resources: [] })); + + await client.query({ entity: 'ITMMASTER', count: 500 }); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(calledUrl).toContain('count=200'); + expect(calledUrl).not.toContain('count=500'); + }); + + it('defaults count to 20 when not specified', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ $resources: [] })); + + await client.query({ entity: 'ITMMASTER' }); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(calledUrl).toContain('count=20'); + }); + + it('returns empty results when $resources is missing', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({})); + + const result = await client.query({ entity: 'ITMMASTER' }); + + expect(result.records).toEqual([]); + expect(result.pagination.returned).toBe(0); + expect(result.pagination.hasMore).toBe(false); + expect(result.pagination.nextUrl).toBeUndefined(); + }); + + it('includes where, orderBy, select in URL when provided', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ $resources: [] })); + + await client.query({ + entity: 'ITMMASTER', + where: "ITMREF like 'A%'", + orderBy: 'ITMREF asc', + select: 'ITMREF,ITMDES1', + }); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(calledUrl).toContain('where='); + expect(calledUrl).toContain('orderBy='); + expect(calledUrl).toContain('select='); + }); + + it('uses custom representation when provided', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ $resources: [] })); + + await client.query({ entity: 'ITMMASTER', representation: 'ITMCUSTOM' }); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(calledUrl).toContain('representation=ITMCUSTOM.$query'); + }); + }); + + describe('read', () => { + it('returns single record wrapped in { record }', async () => { + const record = { ITMREF: 'A001', ITMDES1: 'Widget' }; + fetchSpy.mockResolvedValueOnce(jsonResponse(record)); + + const result = await client.read('ITMMASTER', 'A001'); + + expect(result.record).toEqual(record); + }); + + it('builds correct URL with entity key and default representation', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({})); + + await client.read('ITMMASTER', 'A001'); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(calledUrl).toBe( + "https://x3.example.com:8124/api1/x3/erp/SEED/ITMMASTER('A001')?representation=ITMMASTER.$details", + ); + }); + + it('uses custom representation when provided', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({})); + + await client.read('ITMMASTER', 'A001', 'ITMCUSTOM'); + + const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(calledUrl).toContain('representation=ITMCUSTOM.$details'); + }); + }); + + describe('listEntities', () => { + it('returns array of entity names from $resources', async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + $resources: [ + { $name: 'ITMMASTER' }, + { $name: 'SORDER' }, + { $name: 'BPCUSTOMER' }, + ], + }), + ); + + const entities = await client.listEntities(); + + expect(entities).toEqual(['ITMMASTER', 'SORDER', 'BPCUSTOMER']); + }); + + it('returns empty array when no $resources', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({})); + + const entities = await client.listEntities(); + + expect(entities).toEqual([]); + }); + + it('filters out entries without names', async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + $resources: [{ $name: 'ITMMASTER' }, { other: 'data' }, { $name: 'SORDER' }], + }), + ); + + const entities = await client.listEntities(); + + expect(entities).toEqual(['ITMMASTER', 'SORDER']); + }); + }); + + describe('healthCheck', () => { + it('returns connected status with latency on success', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({})); + + const result = await client.healthCheck(); + + expect(result.status).toBe('connected'); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it('returns error status with message on failure', async () => { + fetchSpy.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + const result = await client.healthCheck(); + + expect(result.status).toContain('error:'); + expect(result.status).toContain('ECONNREFUSED'); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + }); + }); + + describe('error handling', () => { + it('throws on 401 Unauthorized', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({}, 401, 'Unauthorized')); + + await expect(client.query({ entity: 'ITMMASTER' })).rejects.toThrow('HTTP 401: Unauthorized'); + }); + + it('throws on non-JSON response (login redirect)', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'text/html' }), + json: () => Promise.resolve({}), + } as Response); + + await expect(client.query({ entity: 'ITMMASTER' })).rejects.toThrow( + 'Non-JSON response received (possible login redirect)', + ); + }); + + it('throws on timeout via AbortSignal', async () => { + fetchSpy.mockImplementationOnce( + () => Promise.reject(new DOMException('The operation was aborted', 'TimeoutError')), + ); + + await expect(client.query({ entity: 'ITMMASTER' })).rejects.toThrow('aborted'); + }); + + it('throws on 500 Internal Server Error', async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({}, 500, 'Internal Server Error')); + + await expect(client.read('ITMMASTER', 'A001')).rejects.toThrow('HTTP 500: Internal Server Error'); + }); + }); + + describe('TLS configuration', () => { + it('sets NODE_TLS_REJECT_UNAUTHORIZED=0 when rejectUnauthorized is false', async () => { + const insecureClient = new RestClient({ ...mockConfig, rejectUnauthorized: false }); + fetchSpy.mockResolvedValueOnce(jsonResponse({})); + + await insecureClient.healthCheck(); + + expect(process.env['NODE_TLS_REJECT_UNAUTHORIZED']).toBe('0'); + }); + }); +}); diff --git a/src/clients/rest-client.ts b/src/clients/rest-client.ts new file mode 100644 index 0000000..32e015e --- /dev/null +++ b/src/clients/rest-client.ts @@ -0,0 +1,103 @@ +import type { SageConfig, RestQueryOptions, RestQueryResult, RestDetailResult } from '../types/index.js'; + +export class RestClient { + private config: SageConfig; + + constructor(config: SageConfig) { + this.config = config; + } + + private async get(url: string): Promise { + const headers: Record = { + 'Accept': 'application/json', + 'Authorization': `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`, + }; + + if (!this.config.rejectUnauthorized) { + process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0'; + } + + const response = await fetch(url, { + method: 'GET', + headers, + signal: AbortSignal.timeout(15000), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + throw new Error('Non-JSON response received (possible login redirect)'); + } + + return response.json(); + } + + async query(options: RestQueryOptions): Promise { + let url: string; + + if (options.nextUrl) { + url = options.nextUrl; + } else { + const repr = options.representation || options.entity; + url = `${this.config.url}/api1/x3/erp/${this.config.endpoint}/${options.entity}?representation=${repr}.$query`; + + if (options.where) url += `&where=${encodeURIComponent(options.where)}`; + if (options.orderBy) url += `&orderBy=${encodeURIComponent(options.orderBy)}`; + if (options.select) url += `&select=${encodeURIComponent(options.select)}`; + + const count = Math.min(options.count || 20, 200); + url += `&count=${count}`; + } + + const data = await this.get(url) as Record; + const resources = (data?.['$resources'] as unknown[]) || []; + const links = data?.['$links'] as Record | undefined; + const next = links?.['$next'] as Record | undefined; + + return { + records: resources, + pagination: { + returned: resources.length, + hasMore: !!next, + nextUrl: next?.['$url'] as string | undefined, + }, + }; + } + + async read(entity: string, key: string, representation?: string): Promise { + const repr = representation || entity; + const url = `${this.config.url}/api1/x3/erp/${this.config.endpoint}/${entity}('${key}')?representation=${repr}.$details`; + const data = await this.get(url); + return { record: data }; + } + + async listEntities(): Promise { + const url = `${this.config.url}/api1/x3/erp/${this.config.endpoint}`; + const data = await this.get(url) as Record; + const resources = data?.['$resources'] as Array> | undefined; + + if (Array.isArray(resources)) { + return resources + .map((r) => (r['$name'] || r['name'] || r['$title']) as string | undefined) + .filter((name): name is string => !!name); + } + + return []; + } + + async healthCheck(): Promise<{ status: string; latencyMs: number }> { + const start = Date.now(); + try { + await this.get(`${this.config.url}/api1/x3/erp/${this.config.endpoint}`); + return { status: 'connected', latencyMs: Date.now() - start }; + } catch (error) { + return { + status: `error: ${error instanceof Error ? error.message : String(error)}`, + latencyMs: Date.now() - start, + }; + } + } +}