/**
* Test suite for V2RequestBuilder
* Following TDD approach - these tests define the expected behavior
*/
import { V2RequestBuilder, buildV2Url, parseApiResponse, isErrorResponse } from './v2-request-builder.js';
interface TestResult {
name: string;
passed: boolean;
error?: string;
}
const results: TestResult[] = [];
function test(name: string, fn: () => void): void {
try {
fn();
results.push({ name, passed: true });
console.log(`✓ ${name}`);
} catch (error) {
results.push({
name,
passed: false,
error: error instanceof Error ? error.message : String(error)
});
console.log(`✗ ${name}`);
console.log(` Error: ${error instanceof Error ? error.message : String(error)}`);
}
}
function assertEquals<T>(actual: T, expected: T, message?: string): void {
if (actual !== expected) {
throw new Error(message || `Expected ${expected}, got ${actual}`);
}
}
function assertThrows(fn: () => void, expectedMessage?: string): void {
try {
fn();
throw new Error('Expected function to throw an error');
} catch (error) {
if (expectedMessage && error instanceof Error) {
if (!error.message.includes(expectedMessage)) {
throw new Error(`Expected error message to include "${expectedMessage}", got "${error.message}"`);
}
}
}
}
function assertIncludes(actual: string, expected: string): void {
if (!actual.includes(expected)) {
throw new Error(`Expected "${actual}" to include "${expected}"`);
}
}
// Test Suite 1: V2RequestBuilder class instantiation
console.log('\n=== V2RequestBuilder Class Tests ===\n');
test('should create V2RequestBuilder with valid chain ID', () => {
const builder = new V2RequestBuilder(1);
assertEquals(builder.getChainId(), 1);
});
test('should throw error for invalid chain ID (zero)', () => {
assertThrows(() => new V2RequestBuilder(0), 'Invalid chain ID');
});
test('should throw error for invalid chain ID (negative)', () => {
assertThrows(() => new V2RequestBuilder(-1), 'Invalid chain ID');
});
// Test Suite 2: buildUrl method
console.log('\n=== buildUrl Method Tests ===\n');
test('should build URL with chainid as first parameter', () => {
const builder = new V2RequestBuilder(1);
const url = builder.buildUrl({
module: 'account',
action: 'balance',
params: { address: '0x123' },
apiKey: 'testkey'
});
assertIncludes(url, 'https://api.etherscan.io/v2/api?chainid=1');
});
test('should include module and action parameters', () => {
const builder = new V2RequestBuilder(1);
const url = builder.buildUrl({
module: 'account',
action: 'balance',
params: {},
apiKey: 'testkey'
});
assertIncludes(url, 'module=account');
assertIncludes(url, 'action=balance');
});
test('should include additional params', () => {
const builder = new V2RequestBuilder(1);
const url = builder.buildUrl({
module: 'account',
action: 'balance',
params: { address: '0x123', tag: 'latest' },
apiKey: 'testkey'
});
assertIncludes(url, 'address=0x123');
assertIncludes(url, 'tag=latest');
});
test('should include apikey parameter', () => {
const builder = new V2RequestBuilder(1);
const url = builder.buildUrl({
module: 'account',
action: 'balance',
params: {},
apiKey: 'myapikey123'
});
assertIncludes(url, 'apikey=myapikey123');
});
test('should URL encode parameter values', () => {
const builder = new V2RequestBuilder(1);
const url = builder.buildUrl({
module: 'account',
action: 'balance',
params: { address: '0x123 abc' },
apiKey: 'test key'
});
assertIncludes(url, 'address=0x123%20abc');
assertIncludes(url, 'apikey=test%20key');
});
test('should handle boolean parameters', () => {
const builder = new V2RequestBuilder(1);
const url = builder.buildUrl({
module: 'account',
action: 'balance',
params: { verified: true },
apiKey: 'testkey'
});
assertIncludes(url, 'verified=true');
});
test('should handle number parameters', () => {
const builder = new V2RequestBuilder(1);
const url = builder.buildUrl({
module: 'block',
action: 'getblocknobytime',
params: { timestamp: 1234567890 },
apiKey: 'testkey'
});
assertIncludes(url, 'timestamp=1234567890');
});
test('should sort parameters alphabetically for cache-friendly URLs', () => {
const builder = new V2RequestBuilder(1);
const url = builder.buildUrl({
module: 'account',
action: 'balance',
params: { zebra: 'z', apple: 'a', middle: 'm' },
apiKey: 'testkey'
});
// After chainid, params should be sorted
const paramsStart = url.indexOf('chainid=1&') + 'chainid=1&'.length;
const paramsSection = url.substring(paramsStart);
const paramKeys = paramsSection.split('&').map((p: string) => p.split('=')[0]);
// Check that params after chainid are sorted (excluding chainid itself)
const sortedKeys = [...paramKeys].sort();
assertEquals(JSON.stringify(paramKeys), JSON.stringify(sortedKeys), 'Parameters should be sorted alphabetically');
});
test('should handle empty params object', () => {
const builder = new V2RequestBuilder(1);
const url = builder.buildUrl({
module: 'account',
action: 'balance',
params: {},
apiKey: 'testkey'
});
assertIncludes(url, 'chainid=1');
assertIncludes(url, 'module=account');
assertIncludes(url, 'action=balance');
assertIncludes(url, 'apikey=testkey');
});
test('should handle undefined params', () => {
const builder = new V2RequestBuilder(1);
const url = builder.buildUrl({
module: 'account',
action: 'balance',
apiKey: 'testkey'
});
assertIncludes(url, 'chainid=1');
assertIncludes(url, 'module=account');
});
// Test Suite 3: buildUrlForChain method
console.log('\n=== buildUrlForChain Method Tests ===\n');
test('should build URL with different chain ID', () => {
const builder = new V2RequestBuilder(1);
const url = builder.buildUrlForChain(137, {
module: 'account',
action: 'balance',
params: {},
apiKey: 'testkey'
});
assertIncludes(url, 'chainid=137');
});
test('should not affect instance chain ID', () => {
const builder = new V2RequestBuilder(1);
builder.buildUrlForChain(137, {
module: 'account',
action: 'balance',
params: {},
apiKey: 'testkey'
});
assertEquals(builder.getChainId(), 1);
});
test('should throw error for invalid chain ID in buildUrlForChain', () => {
const builder = new V2RequestBuilder(1);
assertThrows(() => builder.buildUrlForChain(0, {
module: 'account',
action: 'balance',
params: {},
apiKey: 'testkey'
}), 'Invalid chain ID');
});
// Test Suite 4: setChainId method
console.log('\n=== setChainId Method Tests ===\n');
test('should update chain ID', () => {
const builder = new V2RequestBuilder(1);
builder.setChainId(137);
assertEquals(builder.getChainId(), 137);
});
test('should throw error when setting invalid chain ID', () => {
const builder = new V2RequestBuilder(1);
assertThrows(() => builder.setChainId(0), 'Invalid chain ID');
});
// Test Suite 5: buildV2Url standalone function
console.log('\n=== buildV2Url Standalone Function Tests ===\n');
test('should build URL with standalone function', () => {
const url = buildV2Url(1, 'account', 'balance', { address: '0x123' }, 'testkey');
assertIncludes(url, 'chainid=1');
assertIncludes(url, 'module=account');
assertIncludes(url, 'action=balance');
assertIncludes(url, 'address=0x123');
});
test('should throw error for invalid chain ID in standalone function', () => {
assertThrows(() => buildV2Url(0, 'account', 'balance', {}, 'testkey'), 'Invalid chain ID');
});
// Test Suite 6: parseApiResponse function
console.log('\n=== parseApiResponse Function Tests ===\n');
test('should parse valid success response', () => {
const response = {
status: '1',
message: 'OK',
result: { data: 'test' }
};
const parsed = parseApiResponse(response);
assertEquals(parsed.status, '1');
assertEquals(parsed.message, 'OK');
assertEquals(JSON.stringify(parsed.result), JSON.stringify({ data: 'test' }));
});
test('should parse valid error response', () => {
const response = {
status: '0',
message: 'NOTOK',
result: 'Error message'
};
const parsed = parseApiResponse(response);
assertEquals(parsed.status, '0');
assertEquals(parsed.message, 'NOTOK');
});
test('should throw error for invalid response structure (missing status)', () => {
const response = {
message: 'OK',
result: 'data'
};
assertThrows(() => parseApiResponse(response), 'Invalid API response');
});
test('should throw error for invalid response structure (missing message)', () => {
const response = {
status: '1',
result: 'data'
};
assertThrows(() => parseApiResponse(response), 'Invalid API response');
});
test('should throw error for invalid response structure (missing result)', () => {
const response = {
status: '1',
message: 'OK'
};
assertThrows(() => parseApiResponse(response), 'Invalid API response');
});
test('should throw error for non-object response', () => {
assertThrows(() => parseApiResponse('string'), 'Invalid API response');
assertThrows(() => parseApiResponse(null), 'Invalid API response');
assertThrows(() => parseApiResponse(123), 'Invalid API response');
});
test('should throw error for invalid status value', () => {
const response = {
status: '2',
message: 'OK',
result: 'data'
};
assertThrows(() => parseApiResponse(response), 'Invalid API response');
});
// Test Suite 7: isErrorResponse function
console.log('\n=== isErrorResponse Function Tests ===\n');
test('should return true for error response (status 0)', () => {
const response = parseApiResponse({
status: '0',
message: 'NOTOK',
result: 'Error'
});
assertEquals(isErrorResponse(response), true);
});
test('should return false for success response (status 1)', () => {
const response = parseApiResponse({
status: '1',
message: 'OK',
result: 'Success'
});
assertEquals(isErrorResponse(response), false);
});
// Summary
console.log('\n=== Test Summary ===\n');
const passed = results.filter(r => r.passed).length;
const failed = results.filter(r => !r.passed).length;
console.log(`Total: ${results.length}`);
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
if (failed > 0) {
console.log('\nFailed tests:');
results.filter(r => !r.passed).forEach(r => {
console.log(` - ${r.name}`);
console.log(` ${r.error}`);
});
process.exit(1);
} else {
console.log('\nAll tests passed! ✓');
process.exit(0);
}