| | |
| | |
| | |
| | |
| |
|
| |
|
| | 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! ✓');
|
| |
|