import { Readable, Stream } from 'node:stream' import { beforeEach, describe, expect, it, vi } from 'vitest' import { APIError, AuthenticationError, FileUploadError, NetworkError, RateLimitError, TimeoutError, ValidationError, } from '../errors/dify-error' import { HttpClient } from './client' const stubFetch = (): ReturnType => { const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) return fetchMock } const getFetchCall = ( fetchMock: ReturnType, index = 0, ): [string, RequestInit | undefined] => { const call = fetchMock.mock.calls[index] if (!call) { throw new Error(`Missing fetch call at index ${index}`) } return call as [string, RequestInit | undefined] } const getDuplex = (init: RequestInit | undefined) => init && 'duplex' in init ? init.duplex : undefined const toHeaderRecord = (headers: HeadersInit | undefined): Record => Object.fromEntries(new Headers(headers).entries()) const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => new Response(JSON.stringify(body), { ...init, headers: { 'content-type': 'application/json', ...init.headers, }, }) const textResponse = (body: string, init: ResponseInit = {}): Response => new Response(body, { ...init, headers: { ...init.headers, }, }) describe('HttpClient', () => { beforeEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() }) it('builds requests with auth headers and JSON content type', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce( jsonResponse({ ok: true }, { status: 200, headers: { 'x-request-id': 'req' } }), ) const client = new HttpClient({ apiKey: 'test' }) const response = await client.request({ method: 'POST', path: '/chat-messages', data: { user: 'u' }, }) expect(response.requestId).toBe('req') expect(fetchMock).toHaveBeenCalledTimes(1) const [url, init] = getFetchCall(fetchMock) expect(url).toBe('https://api.dify.ai/v1/chat-messages') expect(toHeaderRecord(init?.headers)).toMatchObject({ authorization: 'Bearer test', 'content-type': 'application/json', 'user-agent': 'dify-client-node', }) expect(init?.body).toBe(JSON.stringify({ user: 'u' })) }) it('serializes array query params', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 })) const client = new HttpClient({ apiKey: 'test' }) await client.requestRaw({ method: 'GET', path: '/datasets', query: { tag_ids: ['a', 'b'], limit: 2 }, }) const [url] = getFetchCall(fetchMock) expect(new URL(url).searchParams.toString()).toBe('tag_ids=a&tag_ids=b&limit=2') }) it('returns SSE stream helpers', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce( new Response('data: {"text":"hi"}\n\n', { status: 200, headers: { 'x-request-id': 'req' }, }), ) const client = new HttpClient({ apiKey: 'test' }) const stream = await client.requestStream({ method: 'POST', path: '/chat-messages', data: { user: 'u' }, }) expect(stream.status).toBe(200) expect(stream.requestId).toBe('req') await expect(stream.toText()).resolves.toBe('hi') }) it('returns binary stream helpers', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce( new Response('chunk', { status: 200, headers: { 'x-request-id': 'req' }, }), ) const client = new HttpClient({ apiKey: 'test' }) const stream = await client.requestBinaryStream({ method: 'POST', path: '/text-to-audio', data: { user: 'u', text: 'hi' }, }) expect(stream.status).toBe(200) expect(stream.requestId).toBe('req') expect(stream.data).toBeInstanceOf(Readable) }) it('respects form-data headers', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 })) const client = new HttpClient({ apiKey: 'test' }) const form = new FormData() form.append('file', new Blob(['abc']), 'file.txt') await client.requestRaw({ method: 'POST', path: '/files/upload', data: form, }) const [, init] = getFetchCall(fetchMock) expect(toHeaderRecord(init?.headers)).toMatchObject({ authorization: 'Bearer test', }) expect(toHeaderRecord(init?.headers)['content-type']).toBeUndefined() }) it('sends legacy form-data as a readable request body', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 })) const client = new HttpClient({ apiKey: 'test' }) const legacyForm = Object.assign(Readable.from(['chunk']), { append: vi.fn(), getHeaders: () => ({ 'content-type': 'multipart/form-data; boundary=test', }), }) await client.requestRaw({ method: 'POST', path: '/files/upload', data: legacyForm, }) const [, init] = getFetchCall(fetchMock) expect(toHeaderRecord(init?.headers)).toMatchObject({ authorization: 'Bearer test', 'content-type': 'multipart/form-data; boundary=test', }) expect(getDuplex(init)).toBe('half') expect(init?.body).not.toBe(legacyForm) }) it('rejects legacy form-data objects that are not readable streams', async () => { const fetchMock = stubFetch() const client = new HttpClient({ apiKey: 'test' }) const legacyForm = { append: vi.fn(), getHeaders: () => ({ 'content-type': 'multipart/form-data; boundary=test', }), } await expect( client.requestRaw({ method: 'POST', path: '/files/upload', data: legacyForm, }), ).rejects.toBeInstanceOf(FileUploadError) expect(fetchMock).not.toHaveBeenCalled() }) it('accepts legacy pipeable streams that are not Readable instances', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 })) const client = new HttpClient({ apiKey: 'test' }) const legacyStream = new Stream() as Stream & NodeJS.ReadableStream & { append: ReturnType getHeaders: () => Record } legacyStream.readable = true legacyStream.pause = () => legacyStream legacyStream.resume = () => legacyStream legacyStream.append = vi.fn() legacyStream.getHeaders = () => ({ 'content-type': 'multipart/form-data; boundary=test', }) queueMicrotask(() => { legacyStream.emit('data', Buffer.from('chunk')) legacyStream.emit('end') }) await client.requestRaw({ method: 'POST', path: '/files/upload', data: legacyStream as unknown as FormData, }) const [, init] = getFetchCall(fetchMock) expect(getDuplex(init)).toBe('half') }) it('returns buffers for byte responses', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce( new Response(Uint8Array.from([1, 2, 3]), { status: 200, headers: { 'content-type': 'application/octet-stream' }, }), ) const client = new HttpClient({ apiKey: 'test' }) const response = await client.request({ method: 'GET', path: '/files/file-1/preview', responseType: 'bytes', }) expect(Buffer.isBuffer(response.data)).toBe(true) expect(Array.from(response.data.values())).toEqual([1, 2, 3]) }) it('keeps arraybuffer as a backward-compatible binary alias', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce( new Response(Uint8Array.from([4, 5, 6]), { status: 200, headers: { 'content-type': 'application/octet-stream' }, }), ) const client = new HttpClient({ apiKey: 'test' }) const response = await client.request({ method: 'GET', path: '/files/file-1/preview', responseType: 'arraybuffer', }) expect(Buffer.isBuffer(response.data)).toBe(true) expect(Array.from(response.data.values())).toEqual([4, 5, 6]) }) it('returns null for empty no-content responses', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })) const client = new HttpClient({ apiKey: 'test' }) const response = await client.requestRaw({ method: 'GET', path: '/meta', }) expect(response.data).toBeNull() }) it('maps 401 and 429 errors', async () => { const fetchMock = stubFetch() fetchMock .mockResolvedValueOnce(jsonResponse({ message: 'unauthorized' }, { status: 401 })) .mockResolvedValueOnce( jsonResponse({ message: 'rate' }, { status: 429, headers: { 'retry-after': '2' } }), ) const client = new HttpClient({ apiKey: 'test', maxRetries: 0 }) await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toBeInstanceOf( AuthenticationError, ) const error = await client .requestRaw({ method: 'GET', path: '/meta' }) .catch((err: unknown) => err) expect(error).toBeInstanceOf(RateLimitError) expect((error as RateLimitError).retryAfter).toBe(2) }) it('maps validation and upload errors', async () => { const fetchMock = stubFetch() fetchMock .mockResolvedValueOnce(jsonResponse({ message: 'invalid' }, { status: 422 })) .mockResolvedValueOnce(jsonResponse({ message: 'bad upload' }, { status: 400 })) const client = new HttpClient({ apiKey: 'test', maxRetries: 0 }) await expect( client.requestRaw({ method: 'POST', path: '/chat-messages', data: { user: 'u' } }), ).rejects.toBeInstanceOf(ValidationError) await expect( client.requestRaw({ method: 'POST', path: '/files/upload', data: { user: 'u' } }), ).rejects.toBeInstanceOf(FileUploadError) }) it('maps timeout and network errors', async () => { const fetchMock = stubFetch() fetchMock .mockRejectedValueOnce(Object.assign(new Error('timeout'), { name: 'AbortError' })) .mockRejectedValueOnce(new Error('network')) const client = new HttpClient({ apiKey: 'test', maxRetries: 0 }) await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toBeInstanceOf( TimeoutError, ) await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toBeInstanceOf( NetworkError, ) }) it('maps unknown transport failures to NetworkError', async () => { const fetchMock = stubFetch() fetchMock.mockRejectedValueOnce('boom') const client = new HttpClient({ apiKey: 'test', maxRetries: 0 }) await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toMatchObject({ name: 'NetworkError', message: 'Unexpected network error', }) }) it('retries on timeout errors', async () => { const fetchMock = stubFetch() fetchMock .mockRejectedValueOnce(Object.assign(new Error('timeout'), { name: 'AbortError' })) .mockResolvedValueOnce(jsonResponse('ok', { status: 200 })) const client = new HttpClient({ apiKey: 'test', maxRetries: 1, retryDelay: 0 }) await client.requestRaw({ method: 'GET', path: '/meta' }) expect(fetchMock).toHaveBeenCalledTimes(2) }) it('does not retry non-replayable readable request bodies', async () => { const fetchMock = stubFetch() fetchMock.mockRejectedValueOnce(new Error('network')) const client = new HttpClient({ apiKey: 'test', maxRetries: 2, retryDelay: 0 }) await expect( client.requestRaw({ method: 'POST', path: '/chat-messages', data: Readable.from(['chunk']), }), ).rejects.toBeInstanceOf(NetworkError) expect(fetchMock).toHaveBeenCalledTimes(1) const [, init] = getFetchCall(fetchMock) expect(getDuplex(init)).toBe('half') }) it('validates query parameters before request', async () => { const fetchMock = stubFetch() const client = new HttpClient({ apiKey: 'test' }) await expect( client.requestRaw({ method: 'GET', path: '/meta', query: { user: 1 } }), ).rejects.toBeInstanceOf(ValidationError) expect(fetchMock).not.toHaveBeenCalled() }) it('returns APIError for other http failures', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'server' }, { status: 500 })) const client = new HttpClient({ apiKey: 'test', maxRetries: 0 }) await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toBeInstanceOf( APIError, ) }) it('uses plain text bodies when json parsing is not possible', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce( textResponse('plain text', { status: 200, headers: { 'content-type': 'text/plain' }, }), ) const client = new HttpClient({ apiKey: 'test' }) const response = await client.requestRaw({ method: 'GET', path: '/info', }) expect(response.data).toBe('plain text') }) it('keeps invalid json error bodies as API errors', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce( textResponse('{invalid', { status: 500, headers: { 'content-type': 'application/json', 'x-request-id': 'req-500' }, }), ) const client = new HttpClient({ apiKey: 'test', maxRetries: 0 }) await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toMatchObject({ name: 'APIError', statusCode: 500, requestId: 'req-500', responseBody: '{invalid', }) }) it('sends raw string bodies without additional json encoding', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 })) const client = new HttpClient({ apiKey: 'test' }) await client.requestRaw({ method: 'POST', path: '/meta', data: '{"pre":"serialized"}', headers: { 'Content-Type': 'application/custom+json' }, }) const [, init] = getFetchCall(fetchMock) expect(init?.body).toBe('{"pre":"serialized"}') expect(toHeaderRecord(init?.headers)).toMatchObject({ 'content-type': 'application/custom+json', }) }) it('preserves explicit user-agent headers', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }, { status: 200 })) const client = new HttpClient({ apiKey: 'test' }) await client.requestRaw({ method: 'GET', path: '/meta', headers: { 'User-Agent': 'custom-agent' }, }) const [, init] = getFetchCall(fetchMock) expect(toHeaderRecord(init?.headers)).toMatchObject({ 'user-agent': 'custom-agent', }) }) it('logs requests and responses when enableLogging is true', async () => { const fetchMock = stubFetch() fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }, { status: 200 })) const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {}) const client = new HttpClient({ apiKey: 'test', enableLogging: true }) await client.requestRaw({ method: 'GET', path: '/meta' }) expect(consoleInfo).toHaveBeenCalledWith( expect.stringContaining('dify-client-node response 200 GET'), ) }) it('logs retry attempts when enableLogging is true', async () => { const fetchMock = stubFetch() fetchMock .mockRejectedValueOnce(Object.assign(new Error('timeout'), { name: 'AbortError' })) .mockResolvedValueOnce(jsonResponse('ok', { status: 200 })) const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {}) const client = new HttpClient({ apiKey: 'test', maxRetries: 1, retryDelay: 0, enableLogging: true, }) await client.requestRaw({ method: 'GET', path: '/meta' }) expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('dify-client-node retry')) }) })