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:
2026-01-14 23:10:34 +01:00
parent 20462b48b3
commit b9aebb42f1
34 changed files with 6499 additions and 20 deletions

59
tests/client.test.ts Normal file
View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { BinectClient } from '../src/client.js';
import { DocumentsClient } from '../src/clients/documents.js';
import { AttachmentsClient } from '../src/clients/attachments.js';
import { SendingsClient } from '../src/clients/sendings.js';
import { AccountsClient } from '../src/clients/accounts.js';
import { InvoicesClient } from '../src/clients/invoices.js';
describe('BinectClient', () => {
it('creates client with required config', () => {
const client = new BinectClient({
username: 'test@example.com',
password: 'password123',
});
expect(client).toBeInstanceOf(BinectClient);
});
it('creates client with custom baseUrl', () => {
const client = new BinectClient({
username: 'test@example.com',
password: 'password123',
baseUrl: 'https://custom.api.com/v1',
});
expect(client).toBeInstanceOf(BinectClient);
});
describe('sub-clients', () => {
let client: BinectClient;
beforeAll(() => {
client = new BinectClient({
username: 'test@example.com',
password: 'password123',
});
});
it('has documents client', () => {
expect(client.documents).toBeInstanceOf(DocumentsClient);
});
it('has attachments client', () => {
expect(client.attachments).toBeInstanceOf(AttachmentsClient);
});
it('has sendings client', () => {
expect(client.sendings).toBeInstanceOf(SendingsClient);
});
it('has accounts client', () => {
expect(client.accounts).toBeInstanceOf(AccountsClient);
});
it('has invoices client', () => {
expect(client.invoices).toBeInstanceOf(InvoicesClient);
});
});
});

89
tests/errors.test.ts Normal file
View File

@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest';
import { BinectApiError, BinectAuthError } from '../src/errors.js';
describe('BinectApiError', () => {
it('creates error with all properties', () => {
const response = { error: 'Not found', message: 'Document not found' };
const error = new BinectApiError('Not found', 404, '/documents/123', 'GET', response);
expect(error.name).toBe('BinectApiError');
expect(error.message).toBe('Not found');
expect(error.status).toBe(404);
expect(error.endpoint).toBe('/documents/123');
expect(error.method).toBe('GET');
expect(error.response).toEqual(response);
});
it('creates error without response', () => {
const error = new BinectApiError('Server error', 500, '/documents', 'POST');
expect(error.status).toBe(500);
expect(error.response).toBeNull();
});
it('has correct inheritance', () => {
const error = new BinectApiError('Test', 400, '/test', 'GET');
expect(error instanceof Error).toBe(true);
expect(error instanceof BinectApiError).toBe(true);
});
describe('toDetailedString', () => {
it('includes all error details', () => {
const response = {
error: 'Validation error',
message: 'Invalid document format',
details: ['Page size incorrect', 'Missing address'],
};
const error = new BinectApiError('Validation failed', 400, '/documents', 'POST', response);
const detailed = error.toDetailedString();
expect(detailed).toContain('BinectApiError: Validation failed');
expect(detailed).toContain('Status: 400');
expect(detailed).toContain('Endpoint: POST /documents');
expect(detailed).toContain('Error: Validation error');
expect(detailed).toContain('Message: Invalid document format');
expect(detailed).toContain('Details: Page size incorrect, Missing address');
});
it('handles minimal error response', () => {
const error = new BinectApiError('Server error', 500, '/test', 'GET');
const detailed = error.toDetailedString();
expect(detailed).toContain('BinectApiError: Server error');
expect(detailed).toContain('Status: 500');
// Should not contain "Error:" as a separate field (only in class name)
expect(detailed).not.toContain(' Error:');
expect(detailed).not.toContain(' Message:');
});
});
});
describe('BinectAuthError', () => {
it('creates authentication error', () => {
const error = new BinectAuthError('/accounts', 'GET');
expect(error.name).toBe('BinectAuthError');
expect(error.message).toBe('Authentication failed: Invalid credentials');
expect(error.status).toBe(401);
expect(error.endpoint).toBe('/accounts');
expect(error.method).toBe('GET');
});
it('includes response when provided', () => {
const response = { error: 'Unauthorized', message: 'Invalid credentials' };
const error = new BinectAuthError('/accounts', 'GET', response);
expect(error.response).toEqual(response);
});
it('inherits from BinectApiError', () => {
const error = new BinectAuthError('/test', 'GET');
expect(error instanceof Error).toBe(true);
expect(error instanceof BinectApiError).toBe(true);
expect(error instanceof BinectAuthError).toBe(true);
});
});

281
tests/helpers.test.ts Normal file
View File

@@ -0,0 +1,281 @@
import { describe, it, expect } from 'vitest';
import {
isShippable,
isErroneous,
isInPreparation,
isInProductionQueue,
isPrinting,
isSent,
isCanceled,
isTerminal,
isCancelable,
getErrors,
getWarnings,
getInfoMessages,
hasErrors,
hasWarnings,
getStatusDescription,
pollUntil,
} from '../src/helpers.js';
import { DocumentStatus } from '../src/types.js';
import type { Document, ValidationMessage } from '../src/types.js';
// Helper to create mock documents with specific status
function createMockDocument(status: DocumentStatus, validationMessages?: ValidationMessage[]): Document {
return {
id: 12345,
filename: 'test-doc.pdf',
numberOfPages: 1,
status: {
code: status,
text: 'Test status',
},
documentType: 'LETTER',
letter: {
letterType: 'LETTERDATA',
letterData: {
options: {
simplex: false,
color: false,
franking: 'STANDARD_FRANKING',
productionCountry: 'DE',
envelope: 'DINLANG',
},
},
errors: validationMessages ?? [],
},
};
}
describe('Status Predicates', () => {
describe('isShippable', () => {
it('returns true for status SHIPPABLE', () => {
const doc = createMockDocument(DocumentStatus.SHIPPABLE);
expect(isShippable(doc)).toBe(true);
});
it('returns false for other statuses', () => {
expect(isShippable(createMockDocument(DocumentStatus.IN_PREPARATION))).toBe(false);
expect(isShippable(createMockDocument(DocumentStatus.ERRONEOUS))).toBe(false);
expect(isShippable(createMockDocument(DocumentStatus.SENT))).toBe(false);
});
});
describe('isErroneous', () => {
it('returns true for status ERRONEOUS', () => {
const doc = createMockDocument(DocumentStatus.ERRONEOUS);
expect(isErroneous(doc)).toBe(true);
});
it('returns false for other statuses', () => {
expect(isErroneous(createMockDocument(DocumentStatus.SHIPPABLE))).toBe(false);
});
});
describe('isInPreparation', () => {
it('returns true for status IN_PREPARATION', () => {
const doc = createMockDocument(DocumentStatus.IN_PREPARATION);
expect(isInPreparation(doc)).toBe(true);
});
});
describe('isInProductionQueue', () => {
it('returns true for status PRODUCTION_QUEUE', () => {
const doc = createMockDocument(DocumentStatus.PRODUCTION_QUEUE);
expect(isInProductionQueue(doc)).toBe(true);
});
});
describe('isPrinting', () => {
it('returns true for status PRINTING', () => {
const doc = createMockDocument(DocumentStatus.PRINTING);
expect(isPrinting(doc)).toBe(true);
});
});
describe('isSent', () => {
it('returns true for status SENT', () => {
const doc = createMockDocument(DocumentStatus.SENT);
expect(isSent(doc)).toBe(true);
});
});
describe('isCanceled', () => {
it('returns true for status CANCELED', () => {
const doc = createMockDocument(DocumentStatus.CANCELED);
expect(isCanceled(doc)).toBe(true);
});
});
describe('isTerminal', () => {
it('returns true for SENT, CANCELED, and ERRONEOUS', () => {
expect(isTerminal(createMockDocument(DocumentStatus.SENT))).toBe(true);
expect(isTerminal(createMockDocument(DocumentStatus.CANCELED))).toBe(true);
expect(isTerminal(createMockDocument(DocumentStatus.ERRONEOUS))).toBe(true);
});
it('returns false for non-terminal statuses', () => {
expect(isTerminal(createMockDocument(DocumentStatus.IN_PREPARATION))).toBe(false);
expect(isTerminal(createMockDocument(DocumentStatus.SHIPPABLE))).toBe(false);
expect(isTerminal(createMockDocument(DocumentStatus.PRODUCTION_QUEUE))).toBe(false);
expect(isTerminal(createMockDocument(DocumentStatus.PRINTING))).toBe(false);
});
});
describe('isCancelable', () => {
it('returns true for PRODUCTION_QUEUE and PRINTING', () => {
expect(isCancelable(createMockDocument(DocumentStatus.PRODUCTION_QUEUE))).toBe(true);
expect(isCancelable(createMockDocument(DocumentStatus.PRINTING))).toBe(true);
});
it('returns false for non-cancelable statuses', () => {
expect(isCancelable(createMockDocument(DocumentStatus.IN_PREPARATION))).toBe(false);
expect(isCancelable(createMockDocument(DocumentStatus.SENT))).toBe(false);
expect(isCancelable(createMockDocument(DocumentStatus.CANCELED))).toBe(false);
});
});
});
describe('Validation Helpers', () => {
const mockMessages: ValidationMessage[] = [
{ type: 'ERROR', code: 'ERR001', message: 'Error 1' },
{ type: 'ERROR', code: 'ERR002', message: 'Error 2' },
{ type: 'WARNING', code: 'WARN001', message: 'Warning 1' },
{ type: 'INFO', code: 'INFO001', message: 'Info 1' },
];
describe('getErrors', () => {
it('returns only error messages', () => {
const doc = createMockDocument(DocumentStatus.ERRONEOUS, mockMessages);
const errors = getErrors(doc);
expect(errors).toHaveLength(2);
expect(errors.every(m => m.type === 'ERROR')).toBe(true);
});
it('returns empty array when no validation messages', () => {
const doc = createMockDocument(DocumentStatus.SHIPPABLE);
expect(getErrors(doc)).toEqual([]);
});
});
describe('getWarnings', () => {
it('returns only warning messages', () => {
const doc = createMockDocument(DocumentStatus.SHIPPABLE, mockMessages);
const warnings = getWarnings(doc);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.type).toBe('WARNING');
});
});
describe('getInfoMessages', () => {
it('returns only info messages', () => {
const doc = createMockDocument(DocumentStatus.SHIPPABLE, mockMessages);
const info = getInfoMessages(doc);
expect(info).toHaveLength(1);
expect(info[0]?.type).toBe('INFO');
});
});
describe('hasErrors', () => {
it('returns true when document has errors', () => {
const doc = createMockDocument(DocumentStatus.ERRONEOUS, mockMessages);
expect(hasErrors(doc)).toBe(true);
});
it('returns false when document has no errors', () => {
const doc = createMockDocument(DocumentStatus.SHIPPABLE, [
{ type: 'WARNING', code: 'WARN001', message: 'Warning' },
]);
expect(hasErrors(doc)).toBe(false);
});
});
describe('hasWarnings', () => {
it('returns true when document has warnings', () => {
const doc = createMockDocument(DocumentStatus.SHIPPABLE, mockMessages);
expect(hasWarnings(doc)).toBe(true);
});
it('returns false when document has no warnings', () => {
const doc = createMockDocument(DocumentStatus.SHIPPABLE, [
{ type: 'INFO', code: 'INFO001', message: 'Info' },
]);
expect(hasWarnings(doc)).toBe(false);
});
});
});
describe('getStatusDescription', () => {
it('returns correct descriptions for all statuses', () => {
expect(getStatusDescription(DocumentStatus.IN_PREPARATION)).toBe('In preparation');
expect(getStatusDescription(DocumentStatus.SHIPPABLE)).toBe('Ready to ship');
expect(getStatusDescription(DocumentStatus.PRODUCTION_QUEUE)).toBe('In production queue');
expect(getStatusDescription(DocumentStatus.PRINTING)).toBe('Printing');
expect(getStatusDescription(DocumentStatus.SENT)).toBe('Sent');
expect(getStatusDescription(DocumentStatus.CANCELED)).toBe('Canceled');
expect(getStatusDescription(DocumentStatus.ERRONEOUS)).toBe('Has errors');
});
it('returns "Unknown status" for invalid status', () => {
expect(getStatusDescription(999 as DocumentStatus)).toBe('Unknown status');
});
});
describe('Polling Utilities', () => {
describe('pollUntil', () => {
it('returns immediately when condition is met', async () => {
let callCount = 0;
const result = await pollUntil(
async () => {
callCount++;
return { value: 42 };
},
(r) => r.value === 42,
{ intervalMs: 10 }
);
expect(result.value).toBe(42);
expect(callCount).toBe(1);
});
it('polls until condition is met', async () => {
let callCount = 0;
const result = await pollUntil(
async () => {
callCount++;
return { value: callCount };
},
(r) => r.value >= 3,
{ intervalMs: 10 }
);
expect(result.value).toBe(3);
expect(callCount).toBe(3);
});
it('throws when max attempts exceeded', async () => {
await expect(
pollUntil(
async () => ({ value: 1 }),
(r) => r.value > 100,
{ intervalMs: 10, maxAttempts: 3 }
)
).rejects.toThrow('Polling exceeded maximum attempts (3)');
});
it('respects abort signal', async () => {
const controller = new AbortController();
// Abort immediately
controller.abort();
await expect(
pollUntil(
async () => ({ value: 1 }),
() => false,
{ intervalMs: 10, signal: controller.signal }
)
).rejects.toThrow('Polling aborted');
});
});
});

221
tests/http.test.ts Normal file
View 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);
});
});
});

52
tests/types.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import {
DocumentStatus,
EnvelopeType,
FrankingType,
ProductionCountry,
ResponseFormat,
} from '../src/types.js';
describe('Enums', () => {
describe('DocumentStatus', () => {
it('has correct values', () => {
expect(DocumentStatus.IN_PREPARATION).toBe(1);
expect(DocumentStatus.SHIPPABLE).toBe(2);
expect(DocumentStatus.PRODUCTION_QUEUE).toBe(3);
expect(DocumentStatus.PRINTING).toBe(4);
expect(DocumentStatus.SENT).toBe(5);
expect(DocumentStatus.CANCELED).toBe(6);
expect(DocumentStatus.ERRONEOUS).toBe(7);
});
});
describe('EnvelopeType', () => {
it('has correct values', () => {
expect(EnvelopeType.DINLANG).toBe('DINLANG');
expect(EnvelopeType.C4).toBe('C4');
});
});
describe('FrankingType', () => {
it('has correct values', () => {
expect(FrankingType.UNSPECIFIED).toBe('UNSPECIFIED');
expect(FrankingType.STANDARD_FRANKING).toBe('STANDARD_FRANKING');
expect(FrankingType.DV_FRANKING).toBe('DV_FRANKING');
});
});
describe('ProductionCountry', () => {
it('has correct values', () => {
expect(ProductionCountry.UNSPECIFIED).toBe('UNSPECIFIED');
expect(ProductionCountry.DE).toBe('DE');
expect(ProductionCountry.AT).toBe('AT');
});
});
describe('ResponseFormat', () => {
it('has correct values', () => {
expect(ResponseFormat.FULL).toBe('FULL');
expect(ResponseFormat.SHORT).toBe('SHORT');
});
});
});