| |
| |
| |
| |
|
|
|
|
| import fc from 'fast-check';
|
|
|
|
|
| class MockFetch {
|
| constructor() {
|
| this.calls = [];
|
| this.mockResponse = null;
|
| }
|
|
|
| reset() {
|
| this.calls = [];
|
| this.mockResponse = null;
|
| }
|
|
|
| setMockResponse(response) {
|
| this.mockResponse = response;
|
| }
|
|
|
| async fetch(url, options) {
|
| this.calls.push({ url, options });
|
|
|
| if (this.mockResponse) {
|
| return this.mockResponse;
|
| }
|
|
|
|
|
| return {
|
| ok: true,
|
| status: 200,
|
| headers: {
|
| get: (key) => {
|
| if (key === 'content-type') return 'application/json';
|
| return null;
|
| }
|
| },
|
| json: async () => ({ success: true, data: {} })
|
| };
|
| }
|
| }
|
|
|
|
|
| class ApiClient {
|
| constructor(baseURL = 'https://test-backend.example.com') {
|
| this.baseURL = baseURL.replace(/\/$/, '');
|
| this.cache = new Map();
|
| this.requestLogs = [];
|
| this.errorLogs = [];
|
| this.fetchImpl = null;
|
| }
|
|
|
| setFetchImpl(fetchImpl) {
|
| this.fetchImpl = fetchImpl;
|
| }
|
|
|
| buildUrl(endpoint) {
|
| if (!endpoint.startsWith('/')) {
|
| return `${this.baseURL}/${endpoint}`;
|
| }
|
| return `${this.baseURL}${endpoint}`;
|
| }
|
|
|
| async request(method, endpoint, { body, cache = true, ttl = 60000 } = {}) {
|
| const url = this.buildUrl(endpoint);
|
| const cacheKey = `${method}:${url}`;
|
|
|
| if (method === 'GET' && cache && this.cache.has(cacheKey)) {
|
| const cached = this.cache.get(cacheKey);
|
| if (Date.now() - cached.timestamp < ttl) {
|
| return { ok: true, data: cached.data, cached: true };
|
| }
|
| }
|
|
|
| const started = Date.now();
|
| const entry = {
|
| id: `${Date.now()}-${Math.random()}`,
|
| method,
|
| endpoint,
|
| status: 'pending',
|
| duration: 0,
|
| time: new Date().toISOString(),
|
| };
|
|
|
| try {
|
| const fetchFn = this.fetchImpl || fetch;
|
| const response = await fetchFn(url, {
|
| method,
|
| headers: {
|
| 'Content-Type': 'application/json',
|
| },
|
| body: body ? JSON.stringify(body) : undefined,
|
| });
|
|
|
| const duration = Date.now() - started;
|
| entry.duration = Math.round(duration);
|
| entry.status = response.status;
|
|
|
| const contentType = response.headers.get('content-type') || '';
|
| let data = null;
|
| if (contentType.includes('application/json')) {
|
| data = await response.json();
|
| } else if (contentType.includes('text')) {
|
| data = await response.text();
|
| }
|
|
|
| if (!response.ok) {
|
| const error = new Error((data && data.message) || response.statusText || 'Unknown error');
|
| error.status = response.status;
|
| throw error;
|
| }
|
|
|
| if (method === 'GET' && cache) {
|
| this.cache.set(cacheKey, { timestamp: Date.now(), data });
|
| }
|
|
|
| this.requestLogs.push({ ...entry, success: true });
|
| return { ok: true, data };
|
| } catch (error) {
|
| const duration = Date.now() - started;
|
| entry.duration = Math.round(duration);
|
| entry.status = error.status || 'error';
|
| this.requestLogs.push({ ...entry, success: false, error: error.message });
|
| this.errorLogs.push({
|
| message: error.message,
|
| endpoint,
|
| method,
|
| time: new Date().toISOString(),
|
| });
|
| return { ok: false, error: error.message };
|
| }
|
| }
|
|
|
| get(endpoint, options) {
|
| return this.request('GET', endpoint, options);
|
| }
|
|
|
| post(endpoint, body, options = {}) {
|
| return this.request('POST', endpoint, { ...options, body });
|
| }
|
| }
|
|
|
|
|
| const httpMethodGen = fc.constantFrom('GET', 'POST');
|
| const endpointGen = fc.oneof(
|
| fc.constant('/api/health'),
|
| fc.constant('/api/market'),
|
| fc.constant('/api/coins'),
|
| fc.webPath().map(p => `/api/${p}`)
|
| );
|
| const baseURLGen = fc.webUrl({ withFragments: false, withQueryParameters: false });
|
|
|
| |
| |
| |
| |
| |
| |
|
|
|
|
| console.log('Running Property-Based Tests for API Client...\n');
|
|
|
|
|
| console.log('Property 1: All requests use the configured baseURL');
|
| fc.assert(
|
| fc.asyncProperty(
|
| baseURLGen,
|
| httpMethodGen,
|
| endpointGen,
|
| async (baseURL, method, endpoint) => {
|
| const client = new ApiClient(baseURL);
|
| const mockFetch = new MockFetch();
|
| client.setFetchImpl(mockFetch.fetch.bind(mockFetch));
|
|
|
| await client.request(method, endpoint);
|
|
|
|
|
| const expectedBase = baseURL.replace(/\/$/, '');
|
| const actualURL = mockFetch.calls[0].url;
|
|
|
| return actualURL.startsWith(expectedBase);
|
| }
|
| ),
|
| { numRuns: 100 }
|
| );
|
| console.log('✓ Property 1 passed: All requests use the configured baseURL\n');
|
|
|
|
|
| console.log('Property 2: All successful responses have standardized format');
|
| fc.assert(
|
| fc.asyncProperty(
|
| httpMethodGen,
|
| endpointGen,
|
| fc.jsonValue(),
|
| async (method, endpoint, responseData) => {
|
| const client = new ApiClient('https://test.example.com');
|
| const mockFetch = new MockFetch();
|
|
|
| mockFetch.setMockResponse({
|
| ok: true,
|
| status: 200,
|
| headers: {
|
| get: (key) => key === 'content-type' ? 'application/json' : null
|
| },
|
| json: async () => responseData
|
| });
|
|
|
| client.setFetchImpl(mockFetch.fetch.bind(mockFetch));
|
|
|
| const result = await client.request(method, endpoint);
|
|
|
|
|
| return (
|
| typeof result === 'object' &&
|
| result !== null &&
|
| 'ok' in result &&
|
| result.ok === true &&
|
| 'data' in result
|
| );
|
| }
|
| ),
|
| { numRuns: 100 }
|
| );
|
| console.log('✓ Property 2 passed: All successful responses have standardized format\n');
|
|
|
|
|
| console.log('Property 3: All error responses have standardized format');
|
| fc.assert(
|
| fc.asyncProperty(
|
| httpMethodGen,
|
| endpointGen,
|
| fc.integer({ min: 400, max: 599 }),
|
| fc.string({ minLength: 1, maxLength: 100 }),
|
| async (method, endpoint, statusCode, errorMessage) => {
|
| const client = new ApiClient('https://test.example.com');
|
| const mockFetch = new MockFetch();
|
|
|
| mockFetch.setMockResponse({
|
| ok: false,
|
| status: statusCode,
|
| statusText: errorMessage,
|
| headers: {
|
| get: (key) => key === 'content-type' ? 'application/json' : null
|
| },
|
| json: async () => ({ message: errorMessage })
|
| });
|
|
|
| client.setFetchImpl(mockFetch.fetch.bind(mockFetch));
|
|
|
| const result = await client.request(method, endpoint);
|
|
|
|
|
| return (
|
| typeof result === 'object' &&
|
| result !== null &&
|
| 'ok' in result &&
|
| result.ok === false &&
|
| 'error' in result &&
|
| typeof result.error === 'string'
|
| );
|
| }
|
| ),
|
| { numRuns: 100 }
|
| );
|
| console.log('✓ Property 3 passed: All error responses have standardized format\n');
|
|
|
|
|
| console.log('Property 4: All requests are logged for debugging');
|
| fc.assert(
|
| fc.asyncProperty(
|
| httpMethodGen,
|
| endpointGen,
|
| async (method, endpoint) => {
|
| const client = new ApiClient('https://test.example.com');
|
| const mockFetch = new MockFetch();
|
| client.setFetchImpl(mockFetch.fetch.bind(mockFetch));
|
|
|
| const initialLogCount = client.requestLogs.length;
|
| await client.request(method, endpoint);
|
| const finalLogCount = client.requestLogs.length;
|
|
|
|
|
| if (finalLogCount !== initialLogCount + 1) {
|
| return false;
|
| }
|
|
|
|
|
| const logEntry = client.requestLogs[client.requestLogs.length - 1];
|
| return (
|
| typeof logEntry === 'object' &&
|
| logEntry !== null &&
|
| 'method' in logEntry &&
|
| 'endpoint' in logEntry &&
|
| 'status' in logEntry &&
|
| 'duration' in logEntry &&
|
| 'time' in logEntry &&
|
| 'success' in logEntry
|
| );
|
| }
|
| ),
|
| { numRuns: 100 }
|
| );
|
| console.log('✓ Property 4 passed: All requests are logged for debugging\n');
|
|
|
|
|
| console.log('Property 5: Error requests are logged in errorLogs');
|
| fc.assert(
|
| fc.asyncProperty(
|
| httpMethodGen,
|
| endpointGen,
|
| fc.integer({ min: 400, max: 599 }),
|
| async (method, endpoint, statusCode) => {
|
| const client = new ApiClient('https://test.example.com');
|
| const mockFetch = new MockFetch();
|
|
|
| mockFetch.setMockResponse({
|
| ok: false,
|
| status: statusCode,
|
| statusText: 'Error',
|
| headers: {
|
| get: () => 'application/json'
|
| },
|
| json: async () => ({ message: 'Test error' })
|
| });
|
|
|
| client.setFetchImpl(mockFetch.fetch.bind(mockFetch));
|
|
|
| const initialErrorCount = client.errorLogs.length;
|
| await client.request(method, endpoint);
|
| const finalErrorCount = client.errorLogs.length;
|
|
|
|
|
| if (finalErrorCount !== initialErrorCount + 1) {
|
| return false;
|
| }
|
|
|
|
|
| const errorEntry = client.errorLogs[client.errorLogs.length - 1];
|
| return (
|
| typeof errorEntry === 'object' &&
|
| errorEntry !== null &&
|
| 'message' in errorEntry &&
|
| 'endpoint' in errorEntry &&
|
| 'method' in errorEntry &&
|
| 'time' in errorEntry
|
| );
|
| }
|
| ),
|
| { numRuns: 100 }
|
| );
|
| console.log('✓ Property 5 passed: Error requests are logged in errorLogs\n');
|
|
|
| console.log('All property-based tests passed! ✓');
|
|
|