generated from coulomb/repo-seed
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>
222 lines
6.2 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|