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