import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { HttpClient, encodeBasicAuth, DEFAULT_BASE_URL } from '../src/http.js'; import { BinectApiError, BinectAuthError } from '../src/errors.js'; describe('encodeBasicAuth', () => { it('encodes credentials correctly', () => { const encoded = encodeBasicAuth('user@example.com', 'password123'); // "user@example.com:password123" in base64 expect(encoded).toBe('dXNlckBleGFtcGxlLmNvbTpwYXNzd29yZDEyMw=='); }); it('handles special characters', () => { const encoded = encodeBasicAuth('user@example.com', 'p@ss:word!'); const decoded = Buffer.from(encoded, 'base64').toString('utf-8'); expect(decoded).toBe('user@example.com:p@ss:word!'); }); }); describe('DEFAULT_BASE_URL', () => { it('points to Binect API', () => { expect(DEFAULT_BASE_URL).toBe('https://app.binect.de/binectapi/v1'); }); }); describe('HttpClient', () => { let client: HttpClient; let mockFetch: ReturnType; beforeEach(() => { mockFetch = vi.fn(); global.fetch = mockFetch; client = new HttpClient({ baseUrl: 'https://api.example.com', username: 'testuser', password: 'testpass', }); }); afterEach(() => { vi.restoreAllMocks(); }); describe('request', () => { it('makes GET request with auth header', async () => { mockFetch.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), text: async () => JSON.stringify({ id: '123' }), }); const result = await client.request<{ id: string }>({ method: 'GET', path: '/test', }); expect(mockFetch).toHaveBeenCalledWith( 'https://api.example.com/test', expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /), Accept: 'application/json', }), }) ); expect(result.id).toBe('123'); }); it('makes POST request with JSON body', async () => { mockFetch.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), text: async () => JSON.stringify({ success: true }), }); await client.request({ method: 'POST', path: '/test', body: { data: 'test' }, }); expect(mockFetch).toHaveBeenCalledWith( 'https://api.example.com/test', expect.objectContaining({ method: 'POST', body: JSON.stringify({ data: 'test' }), headers: expect.objectContaining({ 'Content-Type': 'application/json', }), }) ); }); it('adds query parameters to URL', async () => { mockFetch.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), text: async () => '{}', }); await client.request({ method: 'GET', path: '/test', query: { limit: 10, offset: 20 }, }); expect(mockFetch).toHaveBeenCalledWith( 'https://api.example.com/test?limit=10&offset=20', expect.any(Object) ); }); it('skips undefined query parameters', async () => { mockFetch.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'application/json' }), text: async () => '{}', }); await client.request({ method: 'GET', path: '/test', query: { limit: 10, offset: undefined }, }); expect(mockFetch).toHaveBeenCalledWith( 'https://api.example.com/test?limit=10', expect.any(Object) ); }); it('handles empty response', async () => { mockFetch.mockResolvedValueOnce({ ok: true, headers: new Headers({ 'content-type': 'text/plain' }), text: async () => '', }); const result = await client.request({ method: 'DELETE', path: '/test' }); expect(result).toBeUndefined(); }); it('throws BinectAuthError on 401', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401, text: async () => JSON.stringify({ error: 'Unauthorized' }), }); await expect( client.request({ method: 'GET', path: '/test' }) ).rejects.toThrow(BinectAuthError); }); it('throws BinectApiError on other errors', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404, text: async () => JSON.stringify({ message: 'Not found' }), }); await expect( client.request({ method: 'GET', path: '/test' }) ).rejects.toThrow(BinectApiError); try { await client.request({ method: 'GET', path: '/test' }); } catch (e) { if (e instanceof BinectApiError) { expect(e.status).toBe(404); expect(e.message).toBe('Not found'); } } }); it('handles non-JSON error response', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500, text: async () => 'Internal Server Error', }); // Now captures the raw text response as the error message await expect( client.request({ method: 'GET', path: '/test' }) ).rejects.toThrow('Internal Server Error'); }); }); describe('requestRaw', () => { it('returns raw response for binary data', async () => { const mockResponse = { ok: true, headers: new Headers({ 'content-type': 'application/pdf' }), blob: async () => new Blob(['pdf content']), }; mockFetch.mockResolvedValueOnce(mockResponse); const response = await client.requestRaw({ method: 'GET', path: '/documents/123/pdf', }); expect(response).toBe(mockResponse); }); it('throws on error response', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404, text: async () => JSON.stringify({ message: 'Document not found' }), }); await expect( client.requestRaw({ method: 'GET', path: '/documents/123/pdf' }) ).rejects.toThrow(BinectApiError); }); }); });