Spaces:
Sleeping
Sleeping
| /** | |
| * 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! ✓'); | |