generated from coulomb/repo-seed
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>
This commit is contained in:
221
tests/http.test.ts
Normal file
221
tests/http.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user