/** * Property-Based Tests for API Client * Feature: admin-ui-modernization, Property 14: Backend API integration * Validates: Requirements 15.1, 15.2, 15.4 */ import fc from 'fast-check'; // Mock fetch for testing 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; } // Default mock response return { ok: true, status: 200, headers: { get: (key) => { if (key === 'content-type') return 'application/json'; return null; } }, json: async () => ({ success: true, data: {} }) }; } } // Simple ApiClient implementation for testing 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 }); } } // Generators for property-based testing 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 }); /** * Property 14: Backend API integration * For any API request made through apiClient, it should: * 1. Use the configured baseURL * 2. Return a standardized response format ({ ok, data } or { ok: false, error }) * 3. Log the request for debugging */ console.log('Running Property-Based Tests for API Client...\n'); // Property 1: All requests use the configured baseURL 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); // Check that the URL starts with the baseURL 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'); // Property 2: All successful responses have standardized format { ok: true, data } 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); // Check standardized response format 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'); // Property 3: All error responses have standardized format { ok: false, error } 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); // Check standardized error response format 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'); // Property 4: All requests are logged for debugging 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; // Check that a log entry was added if (finalLogCount !== initialLogCount + 1) { return false; } // Check that the log entry has required fields 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'); // Property 5: Error requests are logged in errorLogs 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; // Check that an error log entry was added if (finalErrorCount !== initialErrorCount + 1) { return false; } // Check that the error log entry has required fields 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! ✓');