Files
binect-js/tests/http.test.ts
tegwick b9aebb42f1 Add Binect SDK implementation, Explorer, and test suite
SDK (@binect/js):
- BinectClient with domain sub-clients (documents, sendings, accounts,
  attachments, invoices)
- HTTP Basic Auth, native fetch only (no runtime dependencies)
- TypeScript types matching Binect API vocabulary
- Status predicates and polling helpers in helpers.ts
- Structured error handling (BinectApiError, BinectAuthError)

Explorer:
- Standalone browser-based API explorer (explorer/index.html)
- Interactive testing without code

Tests:
- Unit tests for client, types, errors, helpers, http
- E2E tests for upload/delete and send/cancel workflows

Also includes:
- Architecture Decision Records (ADRs)
- Example DIN 5008 letter PDFs for testing
- API specification research notes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 23:10:34 +01:00

222 lines
6.2 KiB
TypeScript

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<typeof vi.fn>;
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);
});
});
});