diff --git a/defi/api-tests/.gitignore b/defi/api-tests/.gitignore new file mode 100644 index 0000000000..5b6f72f855 --- /dev/null +++ b/defi/api-tests/.gitignore @@ -0,0 +1,3 @@ +test-results +junit.xml +junit.json \ No newline at end of file diff --git a/defi/api-tests/README.md b/defi/api-tests/README.md new file mode 100644 index 0000000000..a3fa83b370 --- /dev/null +++ b/defi/api-tests/README.md @@ -0,0 +1,171 @@ +# DefiLlama API Testing Framework + +Type-safe API testing framework for DefiLlama endpoints using Jest and TypeScript. + +## Architecture + +``` +api-tests/ +├── jest.config.js +├── src/ +│ ├── tvl/ # Example: TVL category +│ │ ├── setup.ts +│ │ ├── types.ts +│ │ └── protocols.test.ts +│ └── ... # Add more categories +└── utils/ + ├── config/ + ├── testHelpers.ts + └── validators.ts +``` + +## Test Structure + +Each test file follows this pattern: + +### 1. Basic Response Validation (Common) +- Successful response with valid structure +- Required fields present +- Consistent data structure +- Unique identifiers + +### 2. Data Validation (Endpoint-specific) +- Core data fields validation +- Sorting/ordering checks +- Nested data structures + +### 3. Metadata (Endpoint-specific) +- Categories, chains, timestamps +- URLs, logos, external links +- Relationships (parent protocols, etc.) + +### 4. Edge Cases (Endpoint-specific) +- Null/empty values +- Optional fields +- Extreme values (very large/small) + +## Environment Setup + +Add to `defi/.env`: +```bash +BASE_API_URL='https://api.llama.fi' +BETA_COINS_URL='https://coins.llama.fi' +BETA_STABLECOINS_URL='https://stablecoins.llama.fi' +BETA_YIELDS_URL='https://yields.llama.fi' +BETA_BRIDGES_URL='https://bridges.llama.fi' +BETA_PRO_API_URL='https://pro-api.llama.fi' +``` + +## Running Tests + +```bash +# From defi directory +npm run test:api # All tests +npm run test:api:watch # Watch mode +npm run test:api:coverage # With coverage + +# From api-tests directory +npm test # All tests +npm test -- src/tvl # Specific category +``` + +## Adding New Endpoint Tests + +### 1. Create setup.ts +```typescript +import { createApiClient, ApiClient } from '../../utils/config/apiClient'; +import { YOUR_ENDPOINTS } from '../../utils/config/endpoints'; + +let apiClient: ApiClient | null = null; + +export function initializeYourTests(): ApiClient { + if (!apiClient) { + apiClient = createApiClient(YOUR_ENDPOINTS.BASE_URL); + } + return apiClient; +} + +export { YOUR_ENDPOINTS }; +``` + +### 2. Create types.ts +```typescript +export interface YourType { + id: string; + name: string; + // ... fields +} +``` + +### 3. Create endpoint.test.ts +```typescript +describe('Category - Endpoint', () => { + const apiClient = initializeYourTests(); + let response: ApiResponse; + + beforeAll(async () => { + response = await apiClient.get(YOUR_ENDPOINTS.ENDPOINT); + }); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(response); + expectArrayResponse(response); + expectNonEmptyArray(response.data); + }); + + it('should have required fields', () => { + expectArrayItemsHaveKeys(response.data, ['id', 'name']); + }); + }); + + describe('Data Validation', () => { + // Add endpoint-specific validation + }); + + describe('Edge Cases', () => { + // Add endpoint-specific edge cases + }); +}); +``` + +## Available Helpers + +**utils/testHelpers.ts** +- `expectSuccessfulResponse(response)` - Check 2xx +- `expectArrayResponse(response)` - Validate array +- `expectValidNumber(value)` - Check valid number +- `expectNonNegativeNumber(value)` - Check >= 0 +- `expectNonEmptyString(value)` - Check non-empty string + +**utils/validators.ts** +- `validateProtocol(protocol)` - Validate structure +- `validateArray(data, validator)` - Validate all items + +## CI/CD + +### Jenkins +```groovy +stage('API Tests') { + steps { + sh 'cd defi && npm run test:api' + } +} +``` + +### GitHub Actions +```yaml +- run: cd defi && npm run test:api +``` + +## Best Practices + +1. **Cache responses** - Use `beforeAll` to fetch once +2. **Group by purpose** - Basic → Data → Metadata → Edge Cases +3. **Test samples** - Don't iterate all items (use `.slice(0, 10)`) +4. **Combine similar checks** - Don't create separate tests for each field +5. **Keep it simple** - Avoid over-engineering + +## API Docs + +https://api-docs.defillama.com/ diff --git a/defi/api-tests/jest.config.js b/defi/api-tests/jest.config.js new file mode 100644 index 0000000000..9735bbcd12 --- /dev/null +++ b/defi/api-tests/jest.config.js @@ -0,0 +1,34 @@ +module.exports = { + roots: ['/src'], + testRegex: '(.*\\.test\\.(tsx?|jsx?))$', + transform: { + '^.+\\.ts$': ['ts-jest', { + tsconfig: { + isolatedModules: true, + }, + diagnostics: false, + }], + }, + moduleFileExtensions: ['ts', 'js', 'json', 'node'], + testEnvironment: 'node', + testTimeout: 30000, + verbose: true, + detectOpenHandles: true, + forceExit: true, + maxWorkers: 1, + randomize: true, + reporters: [ + "default", "jest-junit", + ['./reporters/discord-reporter.js', { + outputDirectory: './test-results', + outputName: 'junit-discord.xml', + }], + ['./reporters/json-reporter.js', { + outputDirectory: './test-results', + outputName: 'junit.json', + }] + ], + coverageDirectory: './coverage', + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'], +}; + diff --git a/defi/api-tests/reporters/discord-reporter.js b/defi/api-tests/reporters/discord-reporter.js new file mode 100644 index 0000000000..9bf426046d --- /dev/null +++ b/defi/api-tests/reporters/discord-reporter.js @@ -0,0 +1,150 @@ +// discord-reporter.js +const axios = require('axios'); +const sdk = require('@defillama/sdk'); + +class DiscordReporter { + constructor(globalConfig, options) { + this._globalConfig = globalConfig; + this._options = options; + this.webhookUrl = process.env.DIM_ERROR_CHANNEL_WEBHOOK + } + + async onRunComplete(contexts, results) { + if (!this.webhookUrl) { + console.warn('No Discord webhook URL provided'); + return; + } + + const { numFailedTests, numPassedTests, numTotalTests, testResults } = results; + + console.log(`Total Tests: ${numTotalTests}, Passed: ${numPassedTests}, Failed: ${numFailedTests}`); + + + for (const testResult of testResults) { + const file = testResult.testFilePath.split('/src').pop(); + await sdk.elastic.writeLog('defi-api-test-file', { + file, + passed: testResult.numPassingTests, + failed: testResult.numFailingTests, + timestamp: new Date().toISOString(), + perfStats: testResult.perfStats, + }) + } + + await sdk.elastic.writeLog('defi-api-test-all', { + totalTests: numTotalTests, + passedTests: numPassedTests, + failedTests: numFailedTests, + timestamp: new Date().toISOString(), + }) + + if (numFailedTests === 0) { + // Optional: send success message + // await this.sendSuccessMessage(numPassedTests, numTotalTests); + return; + } + + const failedTests = this.aggregateFailures(testResults); + await this.sendFailureMessage(failedTests, numFailedTests, numTotalTests); + } + + aggregateFailures(testResults) { + const failures = []; + + testResults.forEach(result => { + if (result.numFailingTests > 0) { + result.testResults.forEach(test => { + if (test.status === 'failed') { + failures.push({ + file: result.testFilePath.split('/').pop(), + testName: test.fullName, + error: test.failureMessages[0]?.split('\n')[0] || 'Unknown error', + duration: test.duration + }); + } + }); + } + }); + + return failures; + } + + async sendFailureMessage(failures, numFailed, numTotal) { + console.log(failures) + const embed = { + title: '❌ Jest Tests Failed', + color: 0xff0000, // Red + fields: [ + { + name: 'Summary', + value: `${numFailed}/${numTotal} tests failed`, + inline: true + }, + { + name: 'Failed Tests', + value: failures.slice(0, 10).map(f => + `• **${f.file}**: ${f.testName.substring(0, 80)}${f.testName.length > 80 ? '...' : ''}` + ).join('\n') + (failures.length > 10 ? `\n... and ${failures.length - 10} more` : ''), + inline: false + } + ], + timestamp: new Date().toISOString(), + footer: { + text: `Build: ${process.env.BUILD_NUMBER || 'local'}` + } + }; + + if (failures.length > 0 && false) { + // Remove ANSI escape codes and clean up the error message + const cleanError = failures[0].error + .replace(/\x1B\[31m/g, '**') // Convert ANSI red to Discord bold + .replace(/\x1B\[32m/g, '**') // Convert ANSI green to Discord bold + .replace(/\x1B\[33m/g, '*') // Convert ANSI yellow to Discord italic + .replace(/\x1B\[39m/g, '**') // Convert ANSI default color reset to Discord bold end + .replace(/\x1B\[22m/g, '**') // Convert ANSI reset to Discord bold end + .replace(/\x1B\[2m/g, '*') // Convert ANSI dim to Discord italic + .replace(/\x1B\[\d+m/g, '') // Remove any remaining ANSI codes + .substring(0, 500); + + embed.fields.push({ + name: 'First Error', + value: `\`\`\`\n${cleanError}\n\`\`\``, + inline: false + }); + } + + await this.sendToDiscord({ embeds: [embed] }); + } + + async sendSuccessMessage(numPassed, numTotal) { + const embed = { + title: '✅ All Jest Tests Passed', + color: 0x00ff00, // Green + fields: [ + { + name: 'Summary', + value: `${numPassed}/${numTotal} tests passed`, + inline: true + } + ], + timestamp: new Date().toISOString() + }; + + await this.sendToDiscord({ embeds: [embed] }); + } + + async sendToDiscord(payload) { + if (!this.webhookUrl) { + return; + } + try { + await axios.post(this.webhookUrl, payload, { + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + console.error('Failed to send Discord notification:', error.message); + } + } +} + +module.exports = DiscordReporter; \ No newline at end of file diff --git a/defi/api-tests/reporters/json-reporter.js b/defi/api-tests/reporters/json-reporter.js new file mode 100644 index 0000000000..bb36144d69 --- /dev/null +++ b/defi/api-tests/reporters/json-reporter.js @@ -0,0 +1,20 @@ +// discord-reporter.js +const fs = require('fs/promises') + +class JSONReporter { + constructor(globalConfig, options) { + this._globalConfig = globalConfig; + this._options = options + if (!options.outputDirectory) options.outputDirectory = __dirname + if (!options.outputName) options.outputName = 'junit.json' + } + + async onRunComplete(contexts, results) { + const outputFile = `${this._options.outputDirectory || '.'}/${this._options.outputName}`; + console.log(`Writing JSON test report to ${outputFile}`); + + await fs.writeFile(outputFile, JSON.stringify(results)); + } +} + +module.exports = JSONReporter; \ No newline at end of file diff --git a/defi/api-tests/src/bridges/bridge.test.ts b/defi/api-tests/src/bridges/bridge.test.ts new file mode 100644 index 0000000000..bbcc712a38 --- /dev/null +++ b/defi/api-tests/src/bridges/bridge.test.ts @@ -0,0 +1,269 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { BridgeDetail, isBridgeDetail, BridgesListResponse } from './types'; +import { bridgeDetailSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, +} from '../../utils/testHelpers'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.BRIDGES.BASE_URL); + +describe('Bridges API - Bridge Detail', () => { + // Test with popular bridges + const testBridgeIds = ['84', '77', '51']; // LayerZero, Wormhole, Circle + const responses: Record> = {}; + + beforeAll(async () => { + // First get the list to ensure we have valid IDs + const listResponse = await apiClient.get(endpoints.BRIDGES.BRIDGES); + + let idsToTest = testBridgeIds; + if (listResponse.status === 200 && listResponse.data.bridges.length > 0) { + // Use the first 3 bridges from the list + idsToTest = listResponse.data.bridges.slice(0, 3).map((b) => b.id.toString()); + } + + const results = await Promise.all( + idsToTest.map((id) => + apiClient.get(endpoints.BRIDGES.BRIDGE(id)) + ) + ); + + idsToTest.forEach((id, index) => { + responses[id] = results[index]; + }); + }, 60000); + + Object.keys(testBridgeIds).forEach((_, index) => { + const bridgeId = testBridgeIds[index]; + + describe(`Bridge ID: ${bridgeId}`, () => { + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + const response = responses[bridgeId]; + if (response) { + expectSuccessfulResponse(response); + expect(isBridgeDetail(response.data)).toBe(true); + } + }); + + it('should validate against Zod schema', () => { + const response = responses[bridgeId]; + if (response && response.status === 200) { + const result = bridgeDetailSchema.safeParse(response.data); + if (!result.success) { + console.log('Validation errors:', JSON.stringify(result.error.issues.slice(0, 10), null, 2)); + } + expect(result.success).toBe(true); + } + }); + + it('should have required fields', () => { + const response = responses[bridgeId]; + if (response && response.status === 200) { + const data = response.data; + expect(data).toHaveProperty('id'); + expect(data).toHaveProperty('name'); + expect(data).toHaveProperty('displayName'); + + expect(typeof data.id).toBe('number'); + expect(typeof data.name).toBe('string'); + expect(data.name.length).toBeGreaterThan(0); + expect(typeof data.displayName).toBe('string'); + expect(data.displayName.length).toBeGreaterThan(0); + } + }); + }); + + describe('Volume Metrics Validation', () => { + it('should have valid volume metrics when present', () => { + const response = responses[bridgeId]; + if (response && response.status === 200) { + const data = response.data; + + if (data.lastDailyVolume !== null && data.lastDailyVolume !== undefined) { + expectValidNumber(data.lastDailyVolume); + expectNonNegativeNumber(data.lastDailyVolume); + } + + if (data.weeklyVolume !== null && data.weeklyVolume !== undefined) { + expectValidNumber(data.weeklyVolume); + expectNonNegativeNumber(data.weeklyVolume); + } + + if (data.monthlyVolume !== null && data.monthlyVolume !== undefined) { + expectValidNumber(data.monthlyVolume); + expectNonNegativeNumber(data.monthlyVolume); + } + } + }); + + it('should have monthly volume >= weekly volume when both present', () => { + const response = responses[bridgeId]; + if (response && response.status === 200) { + const data = response.data; + + if ( + data.monthlyVolume !== null && data.monthlyVolume !== undefined && + data.weeklyVolume !== null && data.weeklyVolume !== undefined && + data.weeklyVolume > 0 + ) { + const ratio = data.monthlyVolume / data.weeklyVolume; + expect(ratio).toBeGreaterThan(0.5); + } + } + }); + }); + + describe('Transaction Counts Validation', () => { + it('should have valid transaction counts when present', () => { + const response = responses[bridgeId]; + if (response && response.status === 200) { + const data = response.data; + + if (data.prevDayTxs) { + expect(data.prevDayTxs).toHaveProperty('deposits'); + expect(data.prevDayTxs).toHaveProperty('withdrawals'); + expectNonNegativeNumber(data.prevDayTxs.deposits); + expectNonNegativeNumber(data.prevDayTxs.withdrawals); + } + + if (data.weeklyTxs) { + expect(data.weeklyTxs).toHaveProperty('deposits'); + expect(data.weeklyTxs).toHaveProperty('withdrawals'); + expectNonNegativeNumber(data.weeklyTxs.deposits); + expectNonNegativeNumber(data.weeklyTxs.withdrawals); + } + + if (data.monthlyTxs) { + expect(data.monthlyTxs).toHaveProperty('deposits'); + expect(data.monthlyTxs).toHaveProperty('withdrawals'); + expectNonNegativeNumber(data.monthlyTxs.deposits); + expectNonNegativeNumber(data.monthlyTxs.withdrawals); + } + } + }); + + it('should have monthly txs >= weekly txs when both present', () => { + const response = responses[bridgeId]; + if (response && response.status === 200) { + const data = response.data; + + if (data.monthlyTxs && data.weeklyTxs) { + const monthlyTotal = data.monthlyTxs.deposits + data.monthlyTxs.withdrawals; + const weeklyTotal = data.weeklyTxs.deposits + data.weeklyTxs.withdrawals; + + if (weeklyTotal > 0) { + expect(monthlyTotal).toBeGreaterThanOrEqual(weeklyTotal * 0.5); + } + } + } + }); + }); + + describe('Chain Breakdown Validation', () => { + it('should have chain breakdown when present', () => { + const response = responses[bridgeId]; + if (response && response.status === 200) { + const data = response.data; + + if (data.chainBreakdown) { + expect(typeof data.chainBreakdown).toBe('object'); + const chains = Object.keys(data.chainBreakdown); + expect(chains.length).toBeGreaterThan(0); + } + } + }); + + it('should have valid chain breakdown metrics', () => { + const response = responses[bridgeId]; + if (response && response.status === 200) { + const data = response.data; + + if (data.chainBreakdown) { + const chains = Object.keys(data.chainBreakdown).slice(0, 5); + + chains.forEach((chain) => { + const chainData = data.chainBreakdown![chain]; + + if (chainData.lastDailyVolume !== null && chainData.lastDailyVolume !== undefined) { + expectValidNumber(chainData.lastDailyVolume); + expectNonNegativeNumber(chainData.lastDailyVolume); + } + + if (chainData.weeklyVolume !== null && chainData.weeklyVolume !== undefined) { + expectValidNumber(chainData.weeklyVolume); + expectNonNegativeNumber(chainData.weeklyVolume); + } + }); + } + } + }); + + it('should have transaction counts in chain breakdown when present', () => { + const response = responses[bridgeId]; + if (response && response.status === 200) { + const data = response.data; + + if (data.chainBreakdown) { + const chains = Object.keys(data.chainBreakdown).slice(0, 3); + + chains.forEach((chain) => { + const chainData = data.chainBreakdown![chain]; + + if (chainData.prevDayTxs) { + expect(chainData.prevDayTxs).toHaveProperty('deposits'); + expect(chainData.prevDayTxs).toHaveProperty('withdrawals'); + expectNonNegativeNumber(chainData.prevDayTxs.deposits); + expectNonNegativeNumber(chainData.prevDayTxs.withdrawals); + } + }); + } + } + }); + }); + }); + }); + + describe('Cross-Bridge Comparison', () => { + it('should have different bridge IDs', () => { + const ids = Object.keys(responses) + .filter((id) => responses[id].status === 200) + .map((id) => responses[id].data.id); + + if (ids.length > 1) { + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(ids.length); + } + }); + + it('should have different bridge names', () => { + const names = Object.keys(responses) + .filter((id) => responses[id].status === 200) + .map((id) => responses[id].data.name); + + if (names.length > 1) { + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(names.length); + } + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent bridge ID gracefully', async () => { + const response = await apiClient.get(endpoints.BRIDGES.BRIDGE('99999')); + + // API returns 200 even for non-existent IDs, but data should be minimal or empty + expect(response).toBeDefined(); + expect(response.status).toBeDefined(); + + if (response.status === 200) { + console.log('API returned 200 for non-existent bridge ID:', response.data); + } + }, 30000); + }); +}); + diff --git a/defi/api-tests/src/bridges/bridges.test.ts b/defi/api-tests/src/bridges/bridges.test.ts new file mode 100644 index 0000000000..fb89f0d1cb --- /dev/null +++ b/defi/api-tests/src/bridges/bridges.test.ts @@ -0,0 +1,233 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { BridgesListResponse, isBridgesListResponse } from './types'; +import { bridgesListResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, +} from '../../utils/testHelpers'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.BRIDGES.BASE_URL); + +describe('Bridges API - Bridges List', () => { + let bridgesResponse: ApiResponse; + + beforeAll(async () => { + bridgesResponse = await apiClient.get(endpoints.BRIDGES.BRIDGES); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(bridgesResponse); + expect(isBridgesListResponse(bridgesResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = bridgesListResponseSchema.safeParse(bridgesResponse.data); + if (!result.success) { + console.log('Validation errors:', JSON.stringify(result.error.issues.slice(0, 10), null, 2)); + } + expect(result.success).toBe(true); + }); + + it('should have bridges array', () => { + expect(Array.isArray(bridgesResponse.data.bridges)).toBe(true); + expect(bridgesResponse.data.bridges.length).toBeGreaterThan(0); + }); + + it('should have minimum expected bridges', () => { + expect(bridgesResponse.data.bridges.length).toBeGreaterThan(20); + }); + }); + + describe('Data Quality Validation', () => { + it('should have unique bridge IDs', () => { + const ids = bridgesResponse.data.bridges.map((bridge) => bridge.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(ids.length); + }); + + it('should have unique bridge names', () => { + const names = bridgesResponse.data.bridges.map((bridge) => bridge.name); + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(names.length); + }); + + it('should have bridges with volume data', () => { + const bridgesWithVolume = bridgesResponse.data.bridges.filter( + (bridge) => bridge.lastDailyVolume !== null && bridge.lastDailyVolume !== undefined && bridge.lastDailyVolume > 0 + ); + expect(bridgesWithVolume.length).toBeGreaterThan(10); + }); + + it('should have bridges supporting multiple chains', () => { + const bridgesWithMultipleChains = bridgesResponse.data.bridges.filter( + (bridge) => bridge.chains.length > 1 + ); + expect(bridgesWithMultipleChains.length).toBeGreaterThan(10); + }); + + it('should have well-known bridges', () => { + const bridgeNames = bridgesResponse.data.bridges.map((b) => b.name.toLowerCase()); + + // Check for some well-known bridges + const wellKnownBridges = ['layerzero', 'wormhole', 'circle', 'hyperliquid']; + const foundBridges = wellKnownBridges.filter((name) => bridgeNames.includes(name)); + + expect(foundBridges.length).toBeGreaterThan(0); + }); + }); + + describe('Bridge Item Validation', () => { + it('should have required fields in all bridges', () => { + bridgesResponse.data.bridges.slice(0, 20).forEach((bridge) => { + expect(bridge).toHaveProperty('id'); + expect(bridge).toHaveProperty('name'); + expect(bridge).toHaveProperty('displayName'); + expect(bridge).toHaveProperty('chains'); + + expect(typeof bridge.id).toBe('number'); + expect(typeof bridge.name).toBe('string'); + expect(bridge.name.length).toBeGreaterThan(0); + expect(typeof bridge.displayName).toBe('string'); + expect(bridge.displayName.length).toBeGreaterThan(0); + expect(Array.isArray(bridge.chains)).toBe(true); + }); + }); + + it('should have valid volume metrics when present', () => { + const bridgesWithVolume = bridgesResponse.data.bridges + .filter((bridge) => bridge.lastDailyVolume !== null && bridge.lastDailyVolume !== undefined) + .slice(0, 20); + + if (bridgesWithVolume.length > 0) { + bridgesWithVolume.forEach((bridge) => { + expectValidNumber(bridge.lastDailyVolume!); + expectNonNegativeNumber(bridge.lastDailyVolume!); + }); + } + }); + + it('should have valid weekly volume when present', () => { + const bridgesWithWeeklyVolume = bridgesResponse.data.bridges + .filter((bridge) => bridge.weeklyVolume !== null && bridge.weeklyVolume !== undefined) + .slice(0, 20); + + if (bridgesWithWeeklyVolume.length > 0) { + bridgesWithWeeklyVolume.forEach((bridge) => { + expectValidNumber(bridge.weeklyVolume!); + expectNonNegativeNumber(bridge.weeklyVolume!); + }); + } + }); + + it('should have valid monthly volume when present', () => { + const bridgesWithMonthlyVolume = bridgesResponse.data.bridges + .filter((bridge) => bridge.monthlyVolume !== null && bridge.monthlyVolume !== undefined) + .slice(0, 20); + + if (bridgesWithMonthlyVolume.length > 0) { + bridgesWithMonthlyVolume.forEach((bridge) => { + expectValidNumber(bridge.monthlyVolume!); + expectNonNegativeNumber(bridge.monthlyVolume!); + }); + } + }); + + it('should have valid chain arrays', () => { + bridgesResponse.data.bridges.slice(0, 20).forEach((bridge) => { + expect(Array.isArray(bridge.chains)).toBe(true); + bridge.chains.forEach((chain) => { + expect(typeof chain).toBe('string'); + expect(chain.length).toBeGreaterThan(0); + }); + }); + }); + + it('should have valid URLs when present', () => { + const bridgesWithURL = bridgesResponse.data.bridges + .filter((bridge) => bridge.url && bridge.url !== null) + .slice(0, 20); + + if (bridgesWithURL.length > 0) { + bridgesWithURL.forEach((bridge) => { + expect(typeof bridge.url).toBe('string'); + expect(bridge.url!.length).toBeGreaterThan(0); + expect(bridge.url).toMatch(/^https?:\/\//); + }); + } + }); + }); + + describe('Volume Comparison', () => { + it('should have bridges sorted by volume', () => { + const bridgesWithVolume = bridgesResponse.data.bridges + .filter((bridge) => bridge.lastDailyVolume !== null && bridge.lastDailyVolume !== undefined && bridge.lastDailyVolume > 1000) + .slice(0, 20); + + if (bridgesWithVolume.length > 1) { + // Check if mostly sorted + let sortedPairs = 0; + let totalPairs = 0; + + for (let i = 1; i < bridgesWithVolume.length; i++) { + const prev = Number(bridgesWithVolume[i - 1].lastDailyVolume); + const curr = Number(bridgesWithVolume[i].lastDailyVolume); + totalPairs++; + if (prev >= curr) sortedPairs++; + } + + // At least 60% should be sorted + const sortedPercentage = (sortedPairs / totalPairs) * 100; + expect(sortedPercentage).toBeGreaterThan(60); + } + }); + + it('should have monthly volume >= weekly volume when both present', () => { + const bridgesWithBothVolumes = bridgesResponse.data.bridges + .filter((bridge) => + bridge.monthlyVolume !== null && bridge.monthlyVolume !== undefined && + bridge.weeklyVolume !== null && bridge.weeklyVolume !== undefined + ) + .slice(0, 20); + + if (bridgesWithBothVolumes.length > 0) { + bridgesWithBothVolumes.forEach((bridge) => { + // Monthly should generally be >= weekly (with some tolerance for timing) + const ratio = bridge.monthlyVolume! / bridge.weeklyVolume!; + expect(ratio).toBeGreaterThan(0.5); // Allow some tolerance + }); + } + }); + }); + + describe('Chain Analysis', () => { + it('should have common chains represented', () => { + const allChains = new Set(); + bridgesResponse.data.bridges.forEach((bridge) => { + bridge.chains.forEach((chain) => allChains.add(chain)); + }); + + const commonChains = ['Ethereum', 'Arbitrum', 'Optimism', 'Base', 'Polygon']; + const foundChains = commonChains.filter((chain) => allChains.has(chain)); + + expect(foundChains.length).toBeGreaterThan(3); + }); + + it('should have Ethereum as the most common chain', () => { + const chainCounts: Record = {}; + + bridgesResponse.data.bridges.forEach((bridge) => { + bridge.chains.forEach((chain) => { + chainCounts[chain] = (chainCounts[chain] || 0) + 1; + }); + }); + + const ethereumCount = chainCounts['Ethereum'] || 0; + expect(ethereumCount).toBeGreaterThan(10); + }); + }); +}); + diff --git a/defi/api-tests/src/bridges/schemas.ts b/defi/api-tests/src/bridges/schemas.ts new file mode 100644 index 0000000000..0777f38d96 --- /dev/null +++ b/defi/api-tests/src/bridges/schemas.ts @@ -0,0 +1,96 @@ +import { z } from 'zod'; + +// Transaction counts schema +export const transactionCountsSchema = z.object({ + deposits: z.number(), + withdrawals: z.number(), +}); + +// Chain breakdown item schema +export const chainBreakdownItemSchema = z.object({ + lastHourlyVolume: z.union([z.number(), z.null()]).optional(), + currentDayVolume: z.union([z.number(), z.null()]).optional(), + lastDailyVolume: z.union([z.number(), z.null()]).optional(), + dayBeforeLastVolume: z.union([z.number(), z.null()]).optional(), + weeklyVolume: z.union([z.number(), z.null()]).optional(), + monthlyVolume: z.union([z.number(), z.null()]).optional(), + last24hVolume: z.union([z.number(), z.null()]).optional(), + lastHourlyTxs: transactionCountsSchema.optional(), + currentDayTxs: transactionCountsSchema.optional(), + prevDayTxs: transactionCountsSchema.optional(), + dayBeforeLastTxs: transactionCountsSchema.optional(), + weeklyTxs: transactionCountsSchema.optional(), + monthlyTxs: transactionCountsSchema.optional(), +}); + +// Bridge list item schema +export const bridgeListItemSchema = z.object({ + id: z.number(), + name: z.string(), + displayName: z.string(), + icon: z.string().optional().nullable(), + volumePrevDay: z.union([z.number(), z.null()]).optional(), + volumePrev2Day: z.union([z.number(), z.null()]).optional(), + lastHourlyVolume: z.union([z.number(), z.null()]).optional(), + last24hVolume: z.union([z.number(), z.null()]).optional(), + lastDailyVolume: z.union([z.number(), z.null()]).optional(), + dayBeforeLastVolume: z.union([z.number(), z.null()]).optional(), + weeklyVolume: z.union([z.number(), z.null()]).optional(), + monthlyVolume: z.union([z.number(), z.null()]).optional(), + chains: z.array(z.string()), + destinationChain: z.union([z.string(), z.boolean()]).optional().nullable(), + url: z.string().optional().nullable(), + slug: z.string().optional().nullable(), +}); + +// Bridges list response schema +export const bridgesListResponseSchema = z.object({ + bridges: z.array(bridgeListItemSchema), +}); + +// Bridge detail schema +export const bridgeDetailSchema = z.object({ + id: z.number(), + name: z.string(), + displayName: z.string(), + icon: z.string().optional().nullable(), + lastHourlyVolume: z.union([z.number(), z.null()]).optional(), + currentDayVolume: z.union([z.number(), z.null()]).optional(), + lastDailyVolume: z.union([z.number(), z.null()]).optional(), + dayBeforeLastVolume: z.union([z.number(), z.null()]).optional(), + weeklyVolume: z.union([z.number(), z.null()]).optional(), + monthlyVolume: z.union([z.number(), z.null()]).optional(), + lastHourlyTxs: transactionCountsSchema.optional(), + currentDayTxs: transactionCountsSchema.optional(), + prevDayTxs: transactionCountsSchema.optional(), + dayBeforeLastTxs: transactionCountsSchema.optional(), + weeklyTxs: transactionCountsSchema.optional(), + monthlyTxs: transactionCountsSchema.optional(), + chainBreakdown: z.record(z.string(), chainBreakdownItemSchema).optional(), + chains: z.array(z.string()).optional(), + destinationChain: z.union([z.string(), z.boolean()]).optional().nullable(), + url: z.string().optional().nullable(), + slug: z.string().optional().nullable(), +}); + +// Bridge volume/stats can be arrays or objects - using flexible schema +export const bridgeVolumeResponseSchema = z.union([ + z.array(z.any()), + z.record(z.string(), z.any()), + z.object({}).passthrough(), +]); + +// Bridge day stats response schema +export const bridgeDayStatsResponseSchema = z.union([ + z.array(z.any()), + z.record(z.string(), z.any()), + z.object({}).passthrough(), +]); + +// Transactions response schema +export const transactionsResponseSchema = z.union([ + z.array(z.any()), + z.record(z.string(), z.any()), + z.object({}).passthrough(), +]); + diff --git a/defi/api-tests/src/bridges/types.ts b/defi/api-tests/src/bridges/types.ts new file mode 100644 index 0000000000..66b86f7896 --- /dev/null +++ b/defi/api-tests/src/bridges/types.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; +import { + bridgeListItemSchema, + bridgesListResponseSchema, + bridgeDetailSchema, + transactionCountsSchema, + chainBreakdownItemSchema, +} from './schemas'; + +// Inferred types +export type BridgeListItem = z.infer; +export type BridgesListResponse = z.infer; +export type BridgeDetail = z.infer; +export type TransactionCounts = z.infer; +export type ChainBreakdownItem = z.infer; + +// Type guards +export function isBridgesListResponse(data: any): data is BridgesListResponse { + return ( + data && + typeof data === 'object' && + 'bridges' in data && + Array.isArray(data.bridges) + ); +} + +export function isBridgeDetail(data: any): data is BridgeDetail { + return ( + data && + typeof data === 'object' && + 'id' in data && + 'name' in data && + 'displayName' in data + ); +} + diff --git a/defi/api-tests/src/bridges/volumeAndStats.test.ts b/defi/api-tests/src/bridges/volumeAndStats.test.ts new file mode 100644 index 0000000000..85200c1615 --- /dev/null +++ b/defi/api-tests/src/bridges/volumeAndStats.test.ts @@ -0,0 +1,192 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { expectSuccessfulResponse } from '../../utils/testHelpers'; + +const apiClient = createApiClient(endpoints.BRIDGES.BASE_URL); + +describe('Bridges API - Volume and Stats', () => { + const testChains = ['Ethereum', 'arbitrum', 'optimism']; + + describe('Bridge Volume by Chain', () => { + testChains.forEach((chain) => { + describe(`Chain: ${chain}`, () => { + let response: any; + + beforeAll(async () => { + response = await apiClient.get(endpoints.BRIDGES.BRIDGE_VOLUME(chain)); + }, 30000); + + it('should return successful response', () => { + expectSuccessfulResponse(response); + }); + + it('should return data', () => { + expect(response.data).toBeDefined(); + expect(response.data !== null).toBe(true); + }); + + it('should return consistent data structure', () => { + // Can be array or object + const dataType = Array.isArray(response.data) ? 'array' : typeof response.data; + expect(['array', 'object']).toContain(dataType); + }); + }); + }); + + it('should handle invalid chain gracefully', async () => { + const response = await apiClient.get(endpoints.BRIDGES.BRIDGE_VOLUME('invalid-chain-123')); + + // Should return some response (may be 404 or empty data) + expect(response).toBeDefined(); + expect(response.status).toBeDefined(); + }, 30000); + }); + + describe('Bridge Day Stats', () => { + const timestamps = [ + Math.floor(Date.now() / 1000) - 86400, // Yesterday + Math.floor(Date.now() / 1000) - 86400 * 7, // 7 days ago + Math.floor(Date.now() / 1000) - 86400 * 30, // 30 days ago + ]; + + timestamps.forEach((timestamp, index) => { + const daysAgo = index === 0 ? '1' : index === 1 ? '7' : '30'; + + describe(`${daysAgo} days ago`, () => { + let response: any; + + beforeAll(async () => { + response = await apiClient.get(endpoints.BRIDGES.BRIDGE_DAY_STATS(timestamp, 'ethereum')); + }, 30000); + + it('should return a response', () => { + expect(response).toBeDefined(); + expect(response.status).toBeDefined(); + }); + + it('should return data if successful', () => { + if (response.status >= 200 && response.status < 300) { + expect(response.data).toBeDefined(); + } else { + // API may return errors for certain timestamps + console.log(`Bridge day stats returned status ${response.status} for ${daysAgo} days ago`); + } + }); + + it('should return consistent data structure when successful', () => { + if (response.status >= 200 && response.status < 300) { + const dataType = Array.isArray(response.data) ? 'array' : typeof response.data; + expect(['array', 'object', 'string']).toContain(dataType); + } + }); + }); + }); + + it('should work with different chains', async () => { + const timestamp = 1755561600; + + const responses = await Promise.all( + testChains.map((chain) => + apiClient.get(endpoints.BRIDGES.BRIDGE_DAY_STATS(timestamp, chain)) + ) + ); + + responses.forEach((response, index) => { + expect(response).toBeDefined(); + expect(response.status).toBeDefined(); + console.log(`Bridge day stats for ${testChains[index]}: status ${response.status}`); + }); + }, 60000); + + it('should handle invalid timestamp gracefully', async () => { + const invalidTimestamp = 0; + const response = await apiClient.get( + endpoints.BRIDGES.BRIDGE_DAY_STATS(invalidTimestamp, 'ethereum') + ); + + expect(response).toBeDefined(); + expect(response.status).toBeDefined(); + }, 30000); + + it('should handle future timestamp gracefully', async () => { + const futureTimestamp = Math.floor(Date.now() / 1000) + 86400 * 365; + const response = await apiClient.get( + endpoints.BRIDGES.BRIDGE_DAY_STATS(futureTimestamp, 'ethereum') + ); + + expect(response).toBeDefined(); + expect(response.status).toBeDefined(); + }, 30000); + }); + + describe('Transactions by Bridge ID', () => { + // Test with a single reliable bridge ID (LayerZero) + const testBridgeIds = ['84']; + + testBridgeIds.forEach((bridgeId) => { + describe(`Bridge ID: ${bridgeId}`, () => { + let response: any; + + beforeAll(async () => { + response = await apiClient.get(endpoints.BRIDGES.TRANSACTIONS(bridgeId)); + }, 90000); + + it('should return a response', () => { + expect(response).toBeDefined(); + expect(response.status).toBeDefined(); + }); + + it('should return data if successful', () => { + if (response.status >= 200 && response.status < 300) { + expect(response.data).toBeDefined(); + } else { + // API may return timeouts or errors for some bridge IDs + console.log(`Bridge ${bridgeId} transactions returned status ${response.status}`); + } + }); + + it('should return consistent data structure when successful', () => { + if (response.status >= 200 && response.status < 300) { + const dataType = Array.isArray(response.data) ? 'array' : typeof response.data; + expect(['array', 'object', 'string']).toContain(dataType); + } + }); + + it('should log transaction count if array', () => { + if (response.status >= 200 && response.status < 300 && Array.isArray(response.data)) { + console.log(`Bridge ${bridgeId} has ${response.data.length} transactions`); + expect(response.data.length).toBeGreaterThanOrEqual(0); + } + }); + }); + }); + + it('should handle non-existent bridge ID gracefully', async () => { + const response = await apiClient.get(endpoints.BRIDGES.TRANSACTIONS('99999')); + + expect(response).toBeDefined(); + expect(response.status).toBeDefined(); + }, 30000); + }); + + describe('Endpoint Path Validation', () => { + it('should have correct bridge volume path', () => { + const chain = 'ethereum'; + const path = endpoints.BRIDGES.BRIDGE_VOLUME(chain); + expect(path).toBe(`/bridgevolume/${chain}`); + }); + + it('should have correct bridge day stats path', () => { + const timestamp = 1234567890; + const chain = 'ethereum'; + const path = endpoints.BRIDGES.BRIDGE_DAY_STATS(timestamp, chain); + expect(path).toBe(`/bridgedaystats/${timestamp}/${chain}`); + }); + + it('should have correct transactions path', () => { + const id = '123'; + const path = endpoints.BRIDGES.TRANSACTIONS(id); + expect(path).toBe(`/transactions/${id}`); + }); + }); +}); \ No newline at end of file diff --git a/defi/api-tests/src/coins/block.test.ts b/defi/api-tests/src/coins/block.test.ts new file mode 100644 index 0000000000..c606b1385f --- /dev/null +++ b/defi/api-tests/src/coins/block.test.ts @@ -0,0 +1,315 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { BlockResponse, isBlockResponse } from './types'; +import { blockResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectValidTimestamp, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.COINS.BASE_URL); + +describe('Coins API - Block', () => { + // Test with various chains + const testChains = [ + 'ethereum', + 'polygon', + 'arbitrum', + 'optimism', + 'avalanche', + 'bsc', + ]; + + // Test with different timestamps + const testTimestamps = [ + { name: 'recent', timestamp: Math.floor(Date.now() / 1000) - 3600 }, // 1 hour ago + { name: '1 day ago', timestamp: Math.floor(Date.now() / 1000) - 86400 }, + { name: '1 week ago', timestamp: Math.floor(Date.now() / 1000) - 86400 * 7 }, + { name: '1 month ago', timestamp: Math.floor(Date.now() / 1000) - 86400 * 30 }, + ]; + + testChains.forEach((chain) => { + describe(`Chain: ${chain}`, () => { + testTimestamps.forEach(({ name, timestamp }) => { + describe(`Timestamp: ${name}`, () => { + let response: ApiResponse; + + beforeAll(async () => { + response = await apiClient.get( + endpoints.COINS.BLOCK(chain, timestamp) + ); + }, 60000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + if (response.status === 200) { + expectSuccessfulResponse(response); + expect(response.data).toHaveProperty('height'); + expect(response.data).toHaveProperty('timestamp'); + expect(isBlockResponse(response.data)).toBe(true); + } else { + // Some chains may not have data for old timestamps + expect(response.status).toBeGreaterThanOrEqual(400); + } + }); + + it('should validate against Zod schema', () => { + if (response.status === 200) { + const result = validate( + response.data, + blockResponseSchema, + `Block-${chain}-${name}` + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors:', result.errors.slice(0, 5)); + } + } + }); + }); + + describe('Block Data Validation', () => { + it('should have valid block height', () => { + if (response.status === 200 && response.data.height) { + expectValidNumber(response.data.height); + expect(response.data.height).toBeGreaterThan(0); + expect(Number.isInteger(response.data.height)).toBe(true); + } + }); + + it('should have valid timestamp', () => { + if (response.status === 200 && response.data.timestamp) { + expectValidNumber(response.data.timestamp); + expectValidTimestamp(response.data.timestamp); + } + }); + + it('should have timestamp close to requested timestamp', () => { + if (response.status === 200 && response.data.timestamp) { + const diff = Math.abs(response.data.timestamp - timestamp); + + // Timestamp should be within reasonable range (e.g., 15 minutes) + expect(diff).toBeLessThan(900); + } + }); + + it('should have reasonable block height for chain', () => { + if (response.status === 200 && response.data.height) { + // Different chains have different block heights + // Ethereum has millions of blocks, newer chains fewer + expect(response.data.height).toBeGreaterThan(100); + + // Should not be unreasonably high + expect(response.data.height).toBeLessThan(1e9); + } + }); + }); + + describe('Data Quality Checks', () => { + it('should have timestamp in the past', () => { + if (response.status === 200 && response.data.timestamp) { + const now = Math.floor(Date.now() / 1000); + expect(response.data.timestamp).toBeLessThan(now); + } + }); + }); + }); + }); + + describe(`Block Height Progression for ${chain}`, () => { + it('should show increasing block heights over time', async () => { + const oldTimestamp = Math.floor(Date.now() / 1000) - 86400 * 30; // 30 days ago + const newTimestamp = Math.floor(Date.now() / 1000) - 86400; // 1 day ago + + const oldResponse = await apiClient.get( + endpoints.COINS.BLOCK(chain, oldTimestamp) + ); + const newResponse = await apiClient.get( + endpoints.COINS.BLOCK(chain, newTimestamp) + ); + + if (oldResponse.status === 200 && newResponse.status === 200) { + expect(newResponse.data.height).toBeGreaterThan(oldResponse.data.height); + + // Calculate average block time + const blockDiff = newResponse.data.height - oldResponse.data.height; + const timeDiff = newResponse.data.timestamp - oldResponse.data.timestamp; + const avgBlockTime = timeDiff / blockDiff; + + expect(avgBlockTime).toBeGreaterThan(0); + expect(avgBlockTime).toBeLessThan(60); // Most chains < 60s block time + + console.log(`${chain} average block time: ${avgBlockTime.toFixed(2)}s`); + } + }, 60000); + }); + }); + }); + + describe('Chain Comparison', () => { + it('should compare block heights across chains', async () => { + const timestamp = Math.floor(Date.now() / 1000) - 86400; // 1 day ago + + const responses = await Promise.all( + testChains.map((chain) => + apiClient.get(endpoints.COINS.BLOCK(chain, timestamp)) + ) + ); + + responses.forEach((response, index) => { + if (response.status === 200) { + console.log(`${testChains[index]}: Block ${response.data.height} at ${new Date(response.data.timestamp * 1000).toISOString()}`); + + expect(response.data.height).toBeGreaterThan(0); + expectValidTimestamp(response.data.timestamp); + } + }); + }, 60000); + }); + + describe('Specific Chain Tests', () => { + it('should return Ethereum block for specific timestamp', async () => { + const timestamp = Math.floor(Date.now() / 1000) - 86400; + const response = await apiClient.get( + endpoints.COINS.BLOCK('ethereum', timestamp) + ); + + expect(response.status).toBe(200); + expect(response.data.height).toBeGreaterThan(10000000); // Ethereum has millions of blocks + }); + + it('should return Polygon block for specific timestamp', async () => { + const timestamp = Math.floor(Date.now() / 1000) - 86400; + const response = await apiClient.get( + endpoints.COINS.BLOCK('polygon', timestamp) + ); + + if (response.status === 200) { + expect(response.data.height).toBeGreaterThan(1000000); // Polygon has many blocks + } + }); + + it('should return BSC block for specific timestamp', async () => { + const timestamp = Math.floor(Date.now() / 1000) - 86400; + const response = await apiClient.get( + endpoints.COINS.BLOCK('bsc', timestamp) + ); + + if (response.status === 200) { + expect(response.data.height).toBeGreaterThan(1000000); + } + }); + }); + + describe('Edge Cases', () => { + it('should handle very recent timestamp', async () => { + const recentTimestamp = Math.floor(Date.now() / 1000) - 60; // 1 minute ago + const response = await apiClient.get( + endpoints.COINS.BLOCK('ethereum', recentTimestamp) + ); + + expect(response.status).toBe(200); + expect(response.data.height).toBeGreaterThan(0); + }); + + it('should handle old timestamp', async () => { + // 2 years ago + const oldTimestamp = Math.floor(Date.now() / 1000) - 86400 * 365 * 2; + const response = await apiClient.get( + endpoints.COINS.BLOCK('ethereum', oldTimestamp) + ); + + expect(response.status).toBe(200); + expect(response.data.height).toBeGreaterThan(0); + }); + + it('should handle future timestamp', async () => { + const futureTimestamp = Math.floor(Date.now() / 1000) + 86400; + const response = await apiClient.get( + endpoints.COINS.BLOCK('ethereum', futureTimestamp) + ); + + // Should either return current block or error + expect(response.status).toBeGreaterThanOrEqual(200); + }); + + it('should handle invalid chain gracefully', async () => { + const timestamp = Math.floor(Date.now() / 1000) - 86400; + const response = await apiClient.get( + endpoints.COINS.BLOCK('invalid-chain', timestamp) + ); + + // Should return error for invalid chain + if (response.status !== 200) { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }); + + it('should handle timestamp at chain genesis', async () => { + // Ethereum genesis was in 2015 + const genesisTimestamp = Math.floor(new Date('2015-07-30').getTime() / 1000); + const response = await apiClient.get( + endpoints.COINS.BLOCK('ethereum', genesisTimestamp) + ); + + if (response.status === 200) { + // Should return a very low block number + expect(response.data.height).toBeGreaterThan(0); + expect(response.data.height).toBeLessThan(1000); + } + }); + }); + + describe('Block Time Analysis', () => { + it('should calculate average block time for Ethereum', async () => { + const endTime = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago + const startTime = endTime - 86400; // 24 hours before that + + const endBlock = await apiClient.get( + endpoints.COINS.BLOCK('ethereum', endTime) + ); + const startBlock = await apiClient.get( + endpoints.COINS.BLOCK('ethereum', startTime) + ); + + if (endBlock.status === 200 && startBlock.status === 200) { + const blockDiff = endBlock.data.height - startBlock.data.height; + const timeDiff = endBlock.data.timestamp - startBlock.data.timestamp; + const avgBlockTime = timeDiff / blockDiff; + + // Ethereum block time is around 12 seconds + expect(avgBlockTime).toBeGreaterThan(10); + expect(avgBlockTime).toBeLessThan(15); + + console.log(`Ethereum average block time: ${avgBlockTime.toFixed(2)}s`); + } + }, 60000); + + it('should calculate blocks per day for multiple chains', async () => { + const endTime = Math.floor(Date.now() / 1000) - 3600; + const startTime = endTime - 86400; + + const chainsToTest = ['ethereum', 'polygon', 'bsc']; + + for (const chain of chainsToTest) { + const endBlock = await apiClient.get( + endpoints.COINS.BLOCK(chain, endTime) + ); + const startBlock = await apiClient.get( + endpoints.COINS.BLOCK(chain, startTime) + ); + + if (endBlock.status === 200 && startBlock.status === 200) { + const blocksPerDay = endBlock.data.height - startBlock.data.height; + console.log(`${chain} blocks per day: ${blocksPerDay}`); + + expect(blocksPerDay).toBeGreaterThan(100); + } + } + }, 90000); + }); +}); + diff --git a/defi/api-tests/src/coins/chart.test.ts b/defi/api-tests/src/coins/chart.test.ts new file mode 100644 index 0000000000..6965fa3d27 --- /dev/null +++ b/defi/api-tests/src/coins/chart.test.ts @@ -0,0 +1,291 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { ChartResponse, isChartResponse } from './types'; +import { chartResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectValidTimestamp, + expectFreshData, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.COINS.BASE_URL); + +describe('Coins API - Chart', () => { + const testCases = [ + { + name: 'Bitcoin', + coins: 'coingecko:bitcoin', + minPrice: 10000, + expectedSymbol: 'BTC' + }, + { + name: 'Ethereum', + coins: 'coingecko:ethereum', + minPrice: 1000, + expectedSymbol: 'ETH' + }, + { + name: 'WETH', + coins: 'ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + minPrice: 1000, + expectedSymbol: 'WETH' + }, + { + name: 'Multiple coins', + coins: 'coingecko:bitcoin,coingecko:ethereum', + minPrice: 1000, + expectedSymbol: null + } + ]; + + testCases.forEach(({ name, coins, minPrice, expectedSymbol }) => { + describe(`Chart Data - ${name}`, () => { + let response: ApiResponse; + + beforeAll(async () => { + response = await apiClient.get( + endpoints.COINS.CHART(coins) + ); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(response); + expect(response.data).toHaveProperty('coins'); + expect(typeof response.data.coins).toBe('object'); + expect(isChartResponse(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate( + response.data, + chartResponseSchema, + `Chart-${name}` + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors:', result.errors.slice(0, 5)); + } + }); + + it('should return chart data for requested coins', () => { + const requestedCoins = coins.split(','); + requestedCoins.forEach((coin) => { + expect(response.data.coins[coin]).toBeDefined(); + }); + }); + }); + + describe('Chart Structure Validation', () => { + it('should have valid coin data structure', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + expect(coinData).toHaveProperty('symbol'); + expect(coinData).toHaveProperty('confidence'); + expect(coinData).toHaveProperty('prices'); + + expect(typeof coinData.symbol).toBe('string'); + expect(typeof coinData.confidence).toBe('number'); + expect(Array.isArray(coinData.prices)).toBe(true); + }); + }); + + it('should have non-empty prices array', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + expect(coinData.prices.length).toBeGreaterThan(0); + }); + }); + + it('should have sufficient data points', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + // Should have at least 1 data point + expect(coinData.prices.length).toBeGreaterThan(0); + }); + }); + + it('should have correct symbol if expected', () => { + if (expectedSymbol) { + const firstCoin = Object.values(response.data.coins)[0]; + expect(firstCoin.symbol).toBe(expectedSymbol); + } + }); + }); + + describe('Price Point Validation', () => { + it('should have valid price point structure', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + coinData.prices.slice(0, 20).forEach((point) => { + expect(point).toHaveProperty('timestamp'); + expect(point).toHaveProperty('price'); + + expectValidNumber(point.timestamp); + expectValidNumber(point.price); + }); + }); + }); + + it('should have valid timestamps', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + coinData.prices.slice(0, 20).forEach((point) => { + expectValidTimestamp(point.timestamp); + + // Timestamps should not be in the future + const now = Math.floor(Date.now() / 1000); + expect(point.timestamp).toBeLessThanOrEqual(now); + }); + }); + }); + + it('should have chronologically ordered timestamps', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + for (let i = 0; i < coinData.prices.length - 1; i++) { + expect(coinData.prices[i].timestamp).toBeLessThan(coinData.prices[i + 1].timestamp); + } + }); + }); + + it('should have valid price values', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + coinData.prices.slice(0, 20).forEach((point) => { + expect(point.price).toBeGreaterThan(0); + expect(point.price).toBeLessThan(1e12); + }); + }); + }); + + it('should have reasonable prices based on coin type', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + // Check a few recent prices + const recentPrices = coinData.prices.slice(-10); + recentPrices.forEach((point) => { + expect(point.price).toBeGreaterThan(minPrice * 0.5); // Allow 50% variance + }); + }); + }); + }); + + describe('Data Quality Validation', () => { + it('should have fresh data', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + const timestamps = coinData.prices.map(p => p.timestamp); + expectFreshData(timestamps, 86400 * 7); // Within 7 days + }); + }); + + it('should have valid confidence score', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + expectValidNumber(coinData.confidence); + expect(coinData.confidence).toBeGreaterThanOrEqual(0); + expect(coinData.confidence).toBeLessThanOrEqual(1); + }); + }); + + it('should have reasonable time span', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + if (coinData.prices.length > 1) { + const firstTs = coinData.prices[0].timestamp; + const lastTs = coinData.prices[coinData.prices.length - 1].timestamp; + const spanDays = (lastTs - firstTs) / 86400; + + // If multiple points, check time span + expect(spanDays).toBeGreaterThanOrEqual(0); + } + }); + }); + + it('should not have duplicate timestamps', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + const timestamps = coinData.prices.map(p => p.timestamp); + const uniqueTimestamps = new Set(timestamps); + expect(uniqueTimestamps.size).toBe(timestamps.length); + }); + }); + + it('should have smooth price changes', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + // Check that there are no extreme jumps in consecutive prices + for (let i = 0; i < Math.min(coinData.prices.length - 1, 100); i++) { + const priceChange = Math.abs( + (coinData.prices[i + 1].price - coinData.prices[i].price) / coinData.prices[i].price + ); + + // No single data point should change by more than 50% (unless it's a real market event) + expect(priceChange).toBeLessThan(0.5); + } + }); + }); + }); + + describe('Historical Price Analysis', () => { + it('should have price volatility data', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + const prices = coinData.prices.map(p => p.price); + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + const avgPrice = prices.reduce((a, b) => a + b, 0) / prices.length; + + expect(minPrice).toBeGreaterThan(0); + expect(maxPrice).toBeGreaterThanOrEqual(minPrice); + expect(avgPrice).toBeGreaterThanOrEqual(minPrice); + expect(avgPrice).toBeLessThanOrEqual(maxPrice); + }); + }); + + it('should have price trends', () => { + Object.entries(response.data.coins).forEach(([coinId, coinData]) => { + // Calculate simple trend (first price vs last price) + const firstPrice = coinData.prices[0].price; + const lastPrice = coinData.prices[coinData.prices.length - 1].price; + const percentChange = ((lastPrice - firstPrice) / firstPrice) * 100; + + // Should have some price movement + expect(Math.abs(percentChange)).toBeGreaterThanOrEqual(0); + + // Shouldn't be too extreme (unless it's real market data) + expect(Math.abs(percentChange)).toBeLessThan(10000); + }); + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle single coin', async () => { + const response = await apiClient.get( + endpoints.COINS.CHART('coingecko:bitcoin') + ); + + expect(response.status).toBe(200); + expect(Object.keys(response.data.coins).length).toBe(1); + }); + + it('should handle stablecoin with minimal price movement', async () => { + const response = await apiClient.get( + endpoints.COINS.CHART('ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7') + ); + + if (response.status === 200) { + const usdt = response.data.coins['ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7']; + if (usdt) { + // All prices should be close to $1 + usdt.prices.forEach((point) => { + expect(point.price).toBeGreaterThan(0.95); + expect(point.price).toBeLessThan(1.05); + }); + } + } + }); + + it('should handle invalid coin gracefully', async () => { + const response = await apiClient.get( + endpoints.COINS.CHART('invalid:0x0000000000000000000000000000000000000000') + ); + + // May return error or empty data + expect(response.status).toBeGreaterThanOrEqual(200); + }); + }); +}); + diff --git a/defi/api-tests/src/coins/percentage.test.ts b/defi/api-tests/src/coins/percentage.test.ts new file mode 100644 index 0000000000..6fd775056c --- /dev/null +++ b/defi/api-tests/src/coins/percentage.test.ts @@ -0,0 +1,277 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { PercentageResponse, isPercentageResponse } from './types'; +import { percentageResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.COINS.BASE_URL); + +describe('Coins API - Percentage Change', () => { + const testCases = [ + { + name: 'Major cryptocurrencies', + coins: 'coingecko:bitcoin,coingecko:ethereum', + description: 'BTC and ETH' + }, + { + name: 'Ethereum ecosystem', + coins: 'ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7,ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + description: 'WETH, USDT, USDC' + }, + { + name: 'Multi-chain tokens', + coins: 'ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,polygon:0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270,arbitrum:0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', + description: 'WETH, WMATIC, WETH on Arbitrum' + } + ]; + + testCases.forEach(({ name, coins, description }) => { + describe(`Test Case: ${name} (${description})`, () => { + let response: ApiResponse; + + beforeAll(async () => { + response = await apiClient.get( + endpoints.COINS.PERCENTAGE(coins) + ); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(response); + expect(response.data).toHaveProperty('coins'); + expect(typeof response.data.coins).toBe('object'); + expect(isPercentageResponse(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate( + response.data, + percentageResponseSchema, + `Percentage-${name}` + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors:', result.errors.slice(0, 5)); + } + }); + + it('should return data for all requested coins', () => { + const requestedCoins = coins.split(','); + const receivedCoins = Object.keys(response.data.coins); + + requestedCoins.forEach((coin) => { + expect(receivedCoins).toContain(coin); + }); + }); + + it('should have non-empty coins object', () => { + const coinKeys = Object.keys(response.data.coins); + expect(coinKeys.length).toBeGreaterThan(0); + }); + }); + + describe('Percentage Value Validation', () => { + it('should have valid percentage values', () => { + Object.entries(response.data.coins).forEach(([coinId, percentage]) => { + expectValidNumber(percentage); + expect(Number.isFinite(percentage)).toBe(true); + }); + }); + + it('should have reasonable percentage values', () => { + Object.entries(response.data.coins).forEach(([coinId, percentage]) => { + // Percentage change should typically be between -50% and +50% for 24h + // But we'll allow for extreme cases + expect(percentage).toBeGreaterThan(-99); // Not less than -99% + expect(percentage).toBeLessThan(1000); // Not more than 1000% in 24h + }); + }); + + it('should have different values for different coins', () => { + const percentages = Object.values(response.data.coins); + + // At least some coins should have different percentage changes + const uniquePercentages = new Set(percentages); + if (percentages.length > 1) { + expect(uniquePercentages.size).toBeGreaterThan(0); + } + }); + }); + + describe('Data Quality Checks', () => { + it('should not have NaN values', () => { + Object.entries(response.data.coins).forEach(([coinId, percentage]) => { + expect(Number.isNaN(percentage)).toBe(false); + }); + }); + + it('should not have infinite values', () => { + Object.entries(response.data.coins).forEach(([coinId, percentage]) => { + expect(Number.isFinite(percentage)).toBe(true); + }); + }); + + it('should not have duplicate coin entries', () => { + const coinIds = Object.keys(response.data.coins); + const uniqueCoinIds = new Set(coinIds); + expect(uniqueCoinIds.size).toBe(coinIds.length); + }); + }); + }); + }); + + describe('Specific Token Tests', () => { + it('should return percentage for Bitcoin', async () => { + const response = await apiClient.get( + endpoints.COINS.PERCENTAGE('coingecko:bitcoin') + ); + + expect(response.status).toBe(200); + const btcPercentage = response.data.coins['coingecko:bitcoin']; + expect(btcPercentage).toBeDefined(); + expectValidNumber(btcPercentage); + }); + + it('should return percentage for Ethereum', async () => { + const response = await apiClient.get( + endpoints.COINS.PERCENTAGE('coingecko:ethereum') + ); + + expect(response.status).toBe(200); + const ethPercentage = response.data.coins['coingecko:ethereum']; + expect(ethPercentage).toBeDefined(); + expectValidNumber(ethPercentage); + }); + + it('should return small percentage for stablecoins', async () => { + const response = await apiClient.get( + endpoints.COINS.PERCENTAGE('ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7') + ); + + if (response.status === 200) { + const usdtPercentage = response.data.coins['ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7']; + if (usdtPercentage !== undefined) { + // Stablecoin should have very small percentage change + expect(Math.abs(usdtPercentage)).toBeLessThan(5); // Less than 5% + } + } + }); + }); + + describe('Comparative Analysis', () => { + it('should compare percentage changes across multiple coins', async () => { + const coins = 'coingecko:bitcoin,coingecko:ethereum,ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; + const response = await apiClient.get( + endpoints.COINS.PERCENTAGE(coins) + ); + + expect(response.status).toBe(200); + + const percentages = Object.values(response.data.coins); + expect(percentages.length).toBeGreaterThan(0); + + // Calculate average and standard deviation + const avg = percentages.reduce((a, b) => a + b, 0) / percentages.length; + const stdDev = Math.sqrt( + percentages.reduce((sum, val) => sum + Math.pow(val - avg, 2), 0) / percentages.length + ); + + expect(Number.isFinite(avg)).toBe(true); + expect(Number.isFinite(stdDev)).toBe(true); + }); + + it('should show market correlation', async () => { + // Get percentage for major cryptocurrencies + const coins = 'coingecko:bitcoin,coingecko:ethereum'; + const response = await apiClient.get( + endpoints.COINS.PERCENTAGE(coins) + ); + + if (response.status === 200) { + const btc = response.data.coins['coingecko:bitcoin']; + const eth = response.data.coins['coingecko:ethereum']; + + if (btc !== undefined && eth !== undefined) { + // BTC and ETH typically move in the same direction (though not always) + // Just check they're both valid numbers + expectValidNumber(btc); + expectValidNumber(eth); + } + } + }); + }); + + describe('Edge Cases', () => { + it('should handle single coin request', async () => { + const response = await apiClient.get( + endpoints.COINS.PERCENTAGE('coingecko:bitcoin') + ); + + expect(response.status).toBe(200); + expect(Object.keys(response.data.coins).length).toBe(1); + }); + + it('should handle many coins at once', async () => { + const manyCoins = [ + 'coingecko:bitcoin', + 'coingecko:ethereum', + 'ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + 'ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7', + 'polygon:0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270', + ].join(','); + + const response = await apiClient.get( + endpoints.COINS.PERCENTAGE(manyCoins) + ); + + expect(response.status).toBe(200); + expect(Object.keys(response.data.coins).length).toBeGreaterThan(3); + }); + + it('should handle invalid coin gracefully', async () => { + const response = await apiClient.get( + endpoints.COINS.PERCENTAGE('invalid:0x0000000000000000000000000000000000000000') + ); + + // API may return 200 with empty data or error + if (response.status === 200) { + expect(response.data).toHaveProperty('coins'); + } else { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }); + }); + + describe('Positive/Negative Changes', () => { + it('should have both positive and negative changes in market', async () => { + // Test with many coins to likely get both positive and negative + const coins = [ + 'coingecko:bitcoin', + 'coingecko:ethereum', + 'ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + 'ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7', + 'ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ].join(','); + + const response = await apiClient.get( + endpoints.COINS.PERCENTAGE(coins) + ); + + if (response.status === 200) { + const percentages = Object.values(response.data.coins); + + // In a typical market, not all coins move in the same direction + // But this isn't guaranteed, so we just check the data is valid + percentages.forEach((pct) => { + expectValidNumber(pct); + }); + } + }); + }); +}); + diff --git a/defi/api-tests/src/coins/pricesCurrent.test.ts b/defi/api-tests/src/coins/pricesCurrent.test.ts new file mode 100644 index 0000000000..ec0a56b63e --- /dev/null +++ b/defi/api-tests/src/coins/pricesCurrent.test.ts @@ -0,0 +1,253 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { PricesCurrentResponse, isPricesCurrentResponse } from './types'; +import { pricesCurrentResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, + expectValidTimestamp, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.COINS.BASE_URL); + +describe('Coins API - Prices Current', () => { + // Test with various coin types + const testCases = [ + { + name: 'Ethereum tokens', + coins: 'ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7', + description: 'WETH and USDT' + }, + { + name: 'CoinGecko IDs', + coins: 'coingecko:bitcoin,coingecko:ethereum', + description: 'BTC and ETH via CoinGecko' + }, + { + name: 'Mixed sources', + coins: 'ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,coingecko:bitcoin,bsc:0x2170Ed0880ac9A755fd29B2688956BD959F933F8', + description: 'Ethereum, Bitcoin, and BSC tokens' + }, + { + name: 'Polygon tokens', + coins: 'polygon:0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270,polygon:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', + description: 'WMATIC and USDC on Polygon' + }, + { + name: 'Arbitrum tokens', + coins: 'arbitrum:0x82aF49447D8a07e3bd95BD0d56f35241523fBab1,arbitrum:0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', + description: 'WETH and USDT on Arbitrum' + } + ]; + + testCases.forEach(({ name, coins, description }) => { + describe(`Test Case: ${name} (${description})`, () => { + let response: ApiResponse; + + beforeAll(async () => { + response = await apiClient.get( + endpoints.COINS.PRICES_CURRENT(coins) + ); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(response); + expect(response.data).toHaveProperty('coins'); + expect(typeof response.data.coins).toBe('object'); + expect(isPricesCurrentResponse(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate( + response.data, + pricesCurrentResponseSchema, + `PricesCurrent-${name}` + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors:', result.errors.slice(0, 5)); + } + }); + + it('should return data for all requested coins', () => { + const requestedCoins = coins.split(','); + const receivedCoins = Object.keys(response.data.coins); + + requestedCoins.forEach((coin) => { + expect(receivedCoins).toContain(coin); + }); + }); + + it('should have non-empty coins object', () => { + const coinKeys = Object.keys(response.data.coins); + expect(coinKeys.length).toBeGreaterThan(0); + }); + }); + + describe('Price Data Validation', () => { + it('should have valid price values for all coins', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + expectValidNumber(data.price); + expect(data.price).toBeGreaterThan(0); + }); + }); + + it('should have valid timestamps', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + expectValidNumber(data.timestamp); + expectValidTimestamp(data.timestamp); + + // Timestamp should be recent (within last hour) + const now = Math.floor(Date.now() / 1000); + const age = now - data.timestamp; + expect(age).toBeLessThan(3600); + expect(age).toBeGreaterThanOrEqual(0); + }); + }); + + it('should have valid symbols', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + expect(data.symbol).toBeDefined(); + expect(typeof data.symbol).toBe('string'); + expect(data.symbol.length).toBeGreaterThan(0); + expect(data.symbol.length).toBeLessThan(20); + }); + }); + + it('should have valid confidence scores when present', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + if (data.confidence !== undefined) { + expectValidNumber(data.confidence); + expect(data.confidence).toBeGreaterThanOrEqual(0); + expect(data.confidence).toBeLessThanOrEqual(1); + } + }); + }); + + it('should have valid decimals for ERC20 tokens', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + if (coinId.includes('ethereum:') || coinId.includes('polygon:') || coinId.includes('arbitrum:')) { + if (data.decimals !== undefined) { + expectValidNumber(data.decimals); + expect(data.decimals).toBeGreaterThanOrEqual(0); + expect(data.decimals).toBeLessThanOrEqual(18); + } + } + }); + }); + }); + + describe('Data Quality Checks', () => { + it('should have consistent timestamps across all coins', () => { + const timestamps = Object.values(response.data.coins).map(coin => coin.timestamp); + const uniqueTimestamps = new Set(timestamps); + + // All timestamps should be within reasonable time of each other (20 minutes) + const minTs = Math.min(...timestamps); + const maxTs = Math.max(...timestamps); + expect(maxTs - minTs).toBeLessThan(1200); + }); + + it('should have reasonable price ranges', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + // Prices should be reasonable (not infinity or extremely large) + expect(data.price).toBeLessThan(1e12); + expect(data.price).toBeGreaterThan(1e-12); + }); + }); + + it('should not have duplicate coin entries', () => { + const coinIds = Object.keys(response.data.coins); + const uniqueCoinIds = new Set(coinIds); + expect(uniqueCoinIds.size).toBe(coinIds.length); + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle single coin request', async () => { + const response = await apiClient.get( + endpoints.COINS.PRICES_CURRENT('coingecko:bitcoin') + ); + + expect(response.status).toBe(200); + expect(Object.keys(response.data.coins).length).toBe(1); + expect(response.data.coins['coingecko:bitcoin']).toBeDefined(); + }); + + it('should handle many coins at once', async () => { + const manyCoins = [ + 'coingecko:bitcoin', + 'coingecko:ethereum', + 'ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + 'ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7', + 'ethereum:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + 'polygon:0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270', + 'arbitrum:0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', + ].join(','); + + const response = await apiClient.get( + endpoints.COINS.PRICES_CURRENT(manyCoins) + ); + + expect(response.status).toBe(200); + expect(Object.keys(response.data.coins).length).toBeGreaterThan(5); + }); + + it('should handle invalid coin gracefully', async () => { + const response = await apiClient.get( + endpoints.COINS.PRICES_CURRENT('invalid:0x0000000000000000000000000000000000000000') + ); + + // API may return 200 with empty data or 400/404 + if (response.status === 200) { + expect(response.data).toHaveProperty('coins'); + } else { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }); + }); + + describe('Specific Token Tests', () => { + it('should return correct data for WETH', async () => { + const response = await apiClient.get( + endpoints.COINS.PRICES_CURRENT('ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2') + ); + + const weth = response.data.coins['ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2']; + expect(weth).toBeDefined(); + expect(weth.symbol).toBe('WETH'); + expect(weth.decimals).toBe(18); + expect(weth.price).toBeGreaterThan(1000); // ETH typically > $1000 + }); + + it('should return correct data for Bitcoin', async () => { + const response = await apiClient.get( + endpoints.COINS.PRICES_CURRENT('coingecko:bitcoin') + ); + + const btc = response.data.coins['coingecko:bitcoin']; + expect(btc).toBeDefined(); + expect(btc.symbol).toBe('BTC'); + expect(btc.price).toBeGreaterThan(10000); // BTC typically > $10k + }); + + it('should return correct data for stablecoins', async () => { + const response = await apiClient.get( + endpoints.COINS.PRICES_CURRENT('ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7') + ); + + const usdt = response.data.coins['ethereum:0xdAC17F958D2ee523a2206206994597C13D831ec7']; + expect(usdt).toBeDefined(); + expect(usdt.symbol).toBe('USDT'); + expect(usdt.price).toBeGreaterThan(0.95); + expect(usdt.price).toBeLessThan(1.05); // Stablecoin should be close to $1 + }); + }); +}); + diff --git a/defi/api-tests/src/coins/pricesFirst.test.ts b/defi/api-tests/src/coins/pricesFirst.test.ts new file mode 100644 index 0000000000..dcd57be8d4 --- /dev/null +++ b/defi/api-tests/src/coins/pricesFirst.test.ts @@ -0,0 +1,324 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { PricesFirstResponse, isPricesFirstResponse } from './types'; +import { pricesFirstResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectValidTimestamp, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.COINS.BASE_URL); + +describe('Coins API - Prices First', () => { + const testCases = [ + { + name: 'Bitcoin', + coins: 'coingecko:bitcoin', + expectedSymbol: 'BTC', + description: 'First recorded Bitcoin price' + }, + { + name: 'Ethereum', + coins: 'coingecko:ethereum', + expectedSymbol: 'ETH', + description: 'First recorded Ethereum price' + }, + { + name: 'WETH', + coins: 'ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + expectedSymbol: 'WETH', + description: 'First recorded WETH price' + }, + { + name: 'Multiple coins', + coins: 'coingecko:bitcoin,coingecko:ethereum', + expectedSymbol: null, + description: 'First prices for multiple coins' + } + ]; + + testCases.forEach(({ name, coins, expectedSymbol, description }) => { + describe(`Test Case: ${name} (${description})`, () => { + let response: ApiResponse; + + beforeAll(async () => { + response = await apiClient.get( + endpoints.COINS.PRICES_FIRST(coins) + ); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(response); + expect(response.data).toHaveProperty('coins'); + expect(typeof response.data.coins).toBe('object'); + expect(isPricesFirstResponse(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate( + response.data, + pricesFirstResponseSchema, + `PricesFirst-${name}` + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors:', result.errors.slice(0, 5)); + } + }); + + it('should return data for requested coins', () => { + const requestedCoins = coins.split(','); + requestedCoins.forEach((coin) => { + expect(response.data.coins[coin]).toBeDefined(); + }); + }); + + it('should have correct symbol if expected', () => { + if (expectedSymbol) { + const firstCoin = Object.values(response.data.coins)[0]; + expect(firstCoin.symbol).toBe(expectedSymbol); + } + }); + }); + + describe('First Price Data Validation', () => { + it('should have valid price values', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + expectValidNumber(data.price); + expect(data.price).toBeGreaterThan(0); + expect(data.price).toBeLessThan(1e12); + }); + }); + + it('should have valid timestamps', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + expectValidNumber(data.timestamp); + expectValidTimestamp(data.timestamp); + + // First recorded timestamp should be in the past + const now = Math.floor(Date.now() / 1000); + expect(data.timestamp).toBeLessThan(now); + }); + }); + + it('should have valid symbols', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + expect(data.symbol).toBeDefined(); + expect(typeof data.symbol).toBe('string'); + expect(data.symbol.length).toBeGreaterThan(0); + expect(data.symbol.length).toBeLessThan(20); + }); + }); + + it('should have old timestamps for major coins', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + // Bitcoin launched in 2009, Ethereum in 2015 + if (coinId === 'coingecko:bitcoin') { + const bitcoinLaunch = new Date('2009-01-01').getTime() / 1000; + expect(data.timestamp).toBeGreaterThan(bitcoinLaunch); + } else if (coinId === 'coingecko:ethereum') { + const ethereumLaunch = new Date('2015-01-01').getTime() / 1000; + expect(data.timestamp).toBeGreaterThan(ethereumLaunch); + } + }); + }); + }); + + describe('Historical Context', () => { + it('should have first prices different from current prices', async () => { + // Get current prices for comparison + const currentResponse = await apiClient.get( + endpoints.COINS.PRICES_CURRENT(coins) + ); + + if (currentResponse.status === 200) { + Object.entries(response.data.coins).forEach(([coinId, firstData]) => { + const currentData = (currentResponse.data as any).coins[coinId]; + if (currentData) { + // First price should be different from current (except maybe for stable coins) + const priceDiffPercent = Math.abs( + (currentData.price - firstData.price) / firstData.price * 100 + ); + + // Expect at least some price movement over time + expect(priceDiffPercent).toBeGreaterThanOrEqual(0); + } + }); + } + }); + + it('should show price appreciation over time', async () => { + // For major cryptocurrencies, first price should typically be lower + const currentResponse = await apiClient.get( + endpoints.COINS.PRICES_CURRENT(coins) + ); + + if (currentResponse.status === 200) { + Object.entries(response.data.coins).forEach(([coinId, firstData]) => { + const currentData = (currentResponse.data as any).coins[coinId]; + if (currentData && (coinId === 'coingecko:bitcoin' || coinId === 'coingecko:ethereum')) { + // Major cryptos have generally appreciated + expect(currentData.price).toBeGreaterThan(firstData.price * 0.1); + } + }); + } + }); + }); + + describe('Data Quality Checks', () => { + it('should not have duplicate coin entries', () => { + const coinIds = Object.keys(response.data.coins); + const uniqueCoinIds = new Set(coinIds); + expect(uniqueCoinIds.size).toBe(coinIds.length); + }); + + it('should have consistent data structure', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + expect(data).toHaveProperty('symbol'); + expect(data).toHaveProperty('price'); + expect(data).toHaveProperty('timestamp'); + }); + }); + }); + }); + }); + + describe('Specific Historical Tests', () => { + it('should return Bitcoin first price from early days', async () => { + const response = await apiClient.get( + endpoints.COINS.PRICES_FIRST('coingecko:bitcoin') + ); + + expect(response.status).toBe(200); + const btc = response.data.coins['coingecko:bitcoin']; + expect(btc).toBeDefined(); + expect(btc.symbol).toBe('BTC'); + + // Bitcoin's first recorded price should be very low + expect(btc.price).toBeLessThan(1000); + + // Should be from around 2010-2013 + const date = new Date(btc.timestamp * 1000); + expect(date.getFullYear()).toBeGreaterThanOrEqual(2009); + expect(date.getFullYear()).toBeLessThan(2025); + }); + + it('should return Ethereum first price', async () => { + const response = await apiClient.get( + endpoints.COINS.PRICES_FIRST('coingecko:ethereum') + ); + + expect(response.status).toBe(200); + const eth = response.data.coins['coingecko:ethereum']; + expect(eth).toBeDefined(); + expect(eth.symbol).toBe('ETH'); + + // Ethereum launched in 2015 + const date = new Date(eth.timestamp * 1000); + expect(date.getFullYear()).toBeGreaterThanOrEqual(2015); + }); + + it('should compare first prices of Bitcoin and Ethereum', async () => { + const response = await apiClient.get( + endpoints.COINS.PRICES_FIRST('coingecko:bitcoin,coingecko:ethereum') + ); + + if (response.status === 200) { + const btc = response.data.coins['coingecko:bitcoin']; + const eth = response.data.coins['coingecko:ethereum']; + + if (btc && eth) { + // Bitcoin should have an earlier first timestamp + expect(btc.timestamp).toBeLessThan(eth.timestamp); + } + } + }); + }); + + describe('Edge Cases', () => { + it('should handle single coin request', async () => { + const response = await apiClient.get( + endpoints.COINS.PRICES_FIRST('coingecko:bitcoin') + ); + + expect(response.status).toBe(200); + expect(Object.keys(response.data.coins).length).toBe(1); + }); + + it('should handle newer tokens', async () => { + // Test with a relatively newer token + const response = await apiClient.get( + endpoints.COINS.PRICES_FIRST('ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2') + ); + + if (response.status === 200) { + const weth = response.data.coins['ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2']; + if (weth) { + // WETH should have a relatively recent first timestamp + const date = new Date(weth.timestamp * 1000); + expect(date.getFullYear()).toBeGreaterThanOrEqual(2017); + } + } + }); + + it('should handle invalid coin gracefully', async () => { + const response = await apiClient.get( + endpoints.COINS.PRICES_FIRST('invalid:0x0000000000000000000000000000000000000000') + ); + + // May return error or empty data + if (response.status === 200) { + expect(response.data).toHaveProperty('coins'); + } else { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }); + + it('should handle multiple chains', async () => { + const multiChainCoins = 'ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,polygon:0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270'; + + const response = await apiClient.get( + endpoints.COINS.PRICES_FIRST(multiChainCoins) + ); + + if (response.status === 200) { + expect(Object.keys(response.data.coins).length).toBeGreaterThan(0); + } + }); + }); + + describe('Time Travel Analysis', () => { + it('should show how much prices have changed since launch', async () => { + const coins = 'coingecko:bitcoin,coingecko:ethereum'; + const firstPricesResponse = await apiClient.get( + endpoints.COINS.PRICES_FIRST(coins) + ); + + const currentPricesResponse = await apiClient.get( + endpoints.COINS.PRICES_CURRENT(coins) + ); + + if (firstPricesResponse.status === 200 && currentPricesResponse.status === 200) { + Object.entries(firstPricesResponse.data.coins).forEach(([coinId, firstData]) => { + const currentData = (currentPricesResponse.data as any).coins[coinId]; + + if (currentData) { + const priceMultiple = currentData.price / firstData.price; + const percentageGain = (priceMultiple - 1) * 100; + + // Should show positive returns for major cryptos + expect(priceMultiple).toBeGreaterThan(0); + expect(Number.isFinite(percentageGain)).toBe(true); + + console.log(`${coinId}: ${firstData.price.toFixed(2)} → ${currentData.price.toFixed(2)} (${priceMultiple.toFixed(2)}x, ${percentageGain.toFixed(2)}%)`); + } + }); + } + }); + }); +}); + diff --git a/defi/api-tests/src/coins/pricesHistorical.test.ts b/defi/api-tests/src/coins/pricesHistorical.test.ts new file mode 100644 index 0000000000..0e3ca8eaef --- /dev/null +++ b/defi/api-tests/src/coins/pricesHistorical.test.ts @@ -0,0 +1,203 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { PricesHistoricalResponse, isPricesHistoricalResponse } from './types'; +import { pricesHistoricalResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectValidTimestamp, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.COINS.BASE_URL); + +describe('Coins API - Prices Historical', () => { + const testCoins = 'ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,coingecko:bitcoin'; + + // Test with different historical timestamps + const testTimestamps = [ + { name: '1 day ago', timestamp: Math.floor(Date.now() / 1000) - 86400 }, + { name: '7 days ago', timestamp: Math.floor(Date.now() / 1000) - 86400 * 7 }, + { name: '30 days ago', timestamp: Math.floor(Date.now() / 1000) - 86400 * 30 }, + { name: '1 year ago', timestamp: Math.floor(Date.now() / 1000) - 86400 * 365 }, + ]; + + testTimestamps.forEach(({ name, timestamp }) => { + describe(`Historical Prices - ${name} (${new Date(timestamp * 1000).toISOString()})`, () => { + let response: ApiResponse; + + beforeAll(async () => { + response = await apiClient.get( + endpoints.COINS.PRICES_HISTORICAL(timestamp, testCoins) + ); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(response); + expect(response.data).toHaveProperty('coins'); + expect(typeof response.data.coins).toBe('object'); + expect(isPricesHistoricalResponse(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate( + response.data, + pricesHistoricalResponseSchema, + `PricesHistorical-${name}` + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors:', result.errors.slice(0, 5)); + } + }); + + it('should return data for requested coins', () => { + const coinKeys = Object.keys(response.data.coins); + expect(coinKeys.length).toBeGreaterThan(0); + }); + }); + + describe('Historical Price Data Validation', () => { + it('should have valid price values', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + expectValidNumber(data.price); + expect(data.price).toBeGreaterThan(0); + expect(data.price).toBeLessThan(1e12); + }); + }); + + it('should have timestamps close to requested timestamp', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + expectValidNumber(data.timestamp); + expectValidTimestamp(data.timestamp); + + // Timestamp should be within 1 hour of requested timestamp + const diff = Math.abs(data.timestamp - timestamp); + expect(diff).toBeLessThan(3600); + }); + }); + + it('should have valid symbols', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + expect(data.symbol).toBeDefined(); + expect(typeof data.symbol).toBe('string'); + expect(data.symbol.length).toBeGreaterThan(0); + }); + }); + + it('should have valid confidence scores when present', () => { + Object.entries(response.data.coins).forEach(([coinId, data]) => { + if (data.confidence !== undefined) { + expectValidNumber(data.confidence); + expect(data.confidence).toBeGreaterThanOrEqual(0); + expect(data.confidence).toBeLessThanOrEqual(1); + } + }); + }); + }); + + describe('Data Quality Checks', () => { + it('should return historical prices different from current', async () => { + // Get current prices for comparison + const currentResponse = await apiClient.get( + endpoints.COINS.PRICES_CURRENT(testCoins) + ); + + if (currentResponse.status === 200) { + Object.entries(response.data.coins).forEach(([coinId, historicalData]) => { + const currentData = (currentResponse.data as any).coins[coinId]; + if (currentData) { + // Prices should be different (unless it's very recent) + if (timestamp < Math.floor(Date.now() / 1000) - 3600) { + const priceDiffPercent = Math.abs( + (historicalData.price - currentData.price) / currentData.price * 100 + ); + // Allow for 0% difference in case of stable coins or very stable prices + expect(priceDiffPercent).toBeGreaterThanOrEqual(0); + } + } + }); + } + }); + + it('should have consistent data across coins', () => { + const timestamps = Object.values(response.data.coins).map(coin => coin.timestamp); + const minTs = Math.min(...timestamps); + const maxTs = Math.max(...timestamps); + + // All timestamps should be within 1 hour of each other + expect(maxTs - minTs).toBeLessThan(3600); + }); + }); + }); + }); + + describe('Specific Historical Tests', () => { + it('should return Bitcoin price from 1 year ago', async () => { + const oneYearAgo = Math.floor(Date.now() / 1000) - 86400 * 365; + const response = await apiClient.get( + endpoints.COINS.PRICES_HISTORICAL(oneYearAgo, 'coingecko:bitcoin') + ); + + expect(response.status).toBe(200); + const btc = response.data.coins['coingecko:bitcoin']; + expect(btc).toBeDefined(); + expect(btc.price).toBeGreaterThan(1000); // BTC should always be > $1k + }); + + it('should return ETH price from specific date', async () => { + // Test with a known historical date + const specificDate = Math.floor(new Date('2024-01-01').getTime() / 1000); + const response = await apiClient.get( + endpoints.COINS.PRICES_HISTORICAL(specificDate, 'coingecko:ethereum') + ); + + expect(response.status).toBe(200); + const eth = response.data.coins['coingecko:ethereum']; + expect(eth).toBeDefined(); + expect(eth.price).toBeGreaterThan(100); + }); + }); + + describe('Edge Cases', () => { + it('should handle very old timestamps', async () => { + // 5 years ago + const oldTimestamp = Math.floor(Date.now() / 1000) - 86400 * 365 * 5; + const response = await apiClient.get( + endpoints.COINS.PRICES_HISTORICAL(oldTimestamp, 'coingecko:bitcoin') + ); + + // May return 200 with data or 404 if data not available + if (response.status === 200) { + expect(response.data.coins).toBeDefined(); + } else { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }); + + it('should handle future timestamps', async () => { + const futureTimestamp = Math.floor(Date.now() / 1000) + 86400; + const response = await apiClient.get( + endpoints.COINS.PRICES_HISTORICAL(futureTimestamp, 'coingecko:bitcoin') + ); + + // Should either return current price or error + expect(response.status).toBeGreaterThanOrEqual(200); + }); + + it('should handle multiple chains', async () => { + const timestamp = Math.floor(Date.now() / 1000) - 86400; + const multiChainCoins = 'ethereum:0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2,polygon:0x0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270,arbitrum:0x82aF49447D8a07e3bd95BD0d56f35241523fBab1'; + + const response = await apiClient.get( + endpoints.COINS.PRICES_HISTORICAL(timestamp, multiChainCoins) + ); + + expect(response.status).toBe(200); + expect(Object.keys(response.data.coins).length).toBeGreaterThan(0); + }); + }); +}); + diff --git a/defi/api-tests/src/coins/schemas.ts b/defi/api-tests/src/coins/schemas.ts new file mode 100644 index 0000000000..45e72a6f5f --- /dev/null +++ b/defi/api-tests/src/coins/schemas.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; + +// Common coin price schema +export const coinPriceSchema = z.object({ + decimals: z.number().optional(), + symbol: z.string(), + price: z.number(), + timestamp: z.number(), + confidence: z.number().optional(), +}); + +// Schema for /prices/current/{coins} +export const pricesCurrentResponseSchema = z.object({ + coins: z.record(z.string(), coinPriceSchema), +}); + +// Schema for /prices/historical/{timestamp}/{coins} +export const pricesHistoricalResponseSchema = z.object({ + coins: z.record(z.string(), coinPriceSchema), +}); + +// Schema for /chart/{coins} +export const chartPricePointSchema = z.object({ + timestamp: z.number(), + price: z.number(), +}); + +export const chartCoinDataSchema = z.object({ + symbol: z.string(), + confidence: z.number(), + decimals: z.number().optional(), + prices: z.array(chartPricePointSchema), +}); + +export const chartResponseSchema = z.object({ + coins: z.record(z.string(), chartCoinDataSchema), +}); + +// Schema for /percentage/{coins} +export const percentageResponseSchema = z.object({ + coins: z.record(z.string(), z.number()), +}); + +// Schema for /prices/first/{coins} +export const firstPriceSchema = z.object({ + symbol: z.string(), + price: z.number(), + timestamp: z.number(), +}); + +export const pricesFirstResponseSchema = z.object({ + coins: z.record(z.string(), firstPriceSchema), +}); + +// Schema for /block/{chain}/{timestamp} +export const blockResponseSchema = z.object({ + height: z.number(), + timestamp: z.number(), +}); + diff --git a/defi/api-tests/src/coins/types.ts b/defi/api-tests/src/coins/types.ts new file mode 100644 index 0000000000..366bc941a7 --- /dev/null +++ b/defi/api-tests/src/coins/types.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; +import { + coinPriceSchema, + pricesCurrentResponseSchema, + pricesHistoricalResponseSchema, + chartPricePointSchema, + chartCoinDataSchema, + chartResponseSchema, + percentageResponseSchema, + firstPriceSchema, + pricesFirstResponseSchema, + blockResponseSchema, +} from './schemas'; + +// Infer types from schemas +export type CoinPrice = z.infer; +export type PricesCurrentResponse = z.infer; +export type PricesHistoricalResponse = z.infer; + +export type ChartPricePoint = z.infer; +export type ChartCoinData = z.infer; +export type ChartResponse = z.infer; + +export type PercentageResponse = z.infer; + +export type FirstPrice = z.infer; +export type PricesFirstResponse = z.infer; + +export type BlockResponse = z.infer; + +// Type guards +export function isPricesCurrentResponse(data: unknown): data is PricesCurrentResponse { + return pricesCurrentResponseSchema.safeParse(data).success; +} + +export function isPricesHistoricalResponse(data: unknown): data is PricesHistoricalResponse { + return pricesHistoricalResponseSchema.safeParse(data).success; +} + +export function isChartResponse(data: unknown): data is ChartResponse { + return chartResponseSchema.safeParse(data).success; +} + +export function isPercentageResponse(data: unknown): data is PercentageResponse { + return percentageResponseSchema.safeParse(data).success; +} + +export function isPricesFirstResponse(data: unknown): data is PricesFirstResponse { + return pricesFirstResponseSchema.safeParse(data).success; +} + +export function isBlockResponse(data: unknown): data is BlockResponse { + return blockResponseSchema.safeParse(data).success; +} + diff --git a/defi/api-tests/src/etfs/flows.test.ts b/defi/api-tests/src/etfs/flows.test.ts new file mode 100644 index 0000000000..90177116ae --- /dev/null +++ b/defi/api-tests/src/etfs/flows.test.ts @@ -0,0 +1,215 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { ETFFlowsResponse, isETFFlowsResponse } from './types'; +import { etfFlowsResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, +} from '../../utils/testHelpers'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.ETFS.BASE_URL); + +describe('ETFs API - Flows', () => { + let flowsResponse: ApiResponse; + + beforeAll(async () => { + flowsResponse = await apiClient.get(endpoints.ETFS.FLOWS); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(flowsResponse); + expect(isETFFlowsResponse(flowsResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = etfFlowsResponseSchema.safeParse(flowsResponse.data); + if (!result.success) { + console.log('Validation errors:', JSON.stringify(result.error.issues.slice(0, 10), null, 2)); + } + expect(result.success).toBe(true); + }); + + it('should return a non-empty array', () => { + expect(Array.isArray(flowsResponse.data)).toBe(true); + expect(flowsResponse.data.length).toBeGreaterThan(0); + }); + + it('should have minimum expected flow entries', () => { + expect(flowsResponse.data.length).toBeGreaterThan(10); + }); + }); + + describe('Data Quality Validation', () => { + it('should have valid date strings', () => { + flowsResponse.data.slice(0, 50).forEach((flow) => { + expect(typeof flow.day).toBe('string'); + expect(flow.day.length).toBeGreaterThan(0); + + // Should be a valid ISO date string + const date = new Date(flow.day); + expect(isNaN(date.getTime())).toBe(false); + }); + }); + + it('should have chronologically ordered data', () => { + const dates = flowsResponse.data.map((flow) => new Date(flow.day).getTime()); + + // Data should be in some order (either ascending or descending) + let ascending = 0; + let descending = 0; + + for (let i = 1; i < Math.min(dates.length, 50); i++) { + if (dates[i] >= dates[i - 1]) ascending++; + if (dates[i] <= dates[i - 1]) descending++; + } + + // At least 80% of adjacent pairs should follow the same order + const total = Math.min(dates.length - 1, 49); + expect(Math.max(ascending, descending) / total).toBeGreaterThan(0.8); + }); + + it('should have recent flow data', () => { + const dates = flowsResponse.data.map((flow) => new Date(flow.day).getTime()); + const maxDate = Math.max(...dates); + const nowInMs = Date.now(); + const ageInDays = (nowInMs - maxDate) / (86400 * 1000); + + // Data should be less than 30 days old + expect(ageInDays).toBeLessThan(30); + }); + + it('should have flows for different assets', () => { + const assets = new Set(flowsResponse.data.map((flow) => flow.gecko_id)); + expect(assets.size).toBeGreaterThan(0); + }); + + it('should have both positive and negative flows', () => { + const validFlows = flowsResponse.data + .filter((flow) => flow.total_flow_usd !== null && flow.total_flow_usd !== undefined); + + if (validFlows.length > 10) { + const positiveFlows = validFlows.filter((flow) => flow.total_flow_usd! > 0); + const negativeFlows = validFlows.filter((flow) => flow.total_flow_usd! < 0); + + // Should have at least some positive or negative flows + expect(positiveFlows.length + negativeFlows.length).toBeGreaterThan(0); + } + }); + }); + + describe('Flow Item Validation', () => { + it('should have required fields in all flow entries', () => { + flowsResponse.data.slice(0, 50).forEach((flow) => { + expect(flow).toHaveProperty('gecko_id'); + expect(flow).toHaveProperty('day'); + expect(flow).toHaveProperty('total_flow_usd'); + + expect(typeof flow.gecko_id).toBe('string'); + expect(flow.gecko_id.length).toBeGreaterThan(0); + expect(typeof flow.day).toBe('string'); + expect(flow.day.length).toBeGreaterThan(0); + }); + }); + + it('should have valid flow values when present', () => { + const flowsWithValues = flowsResponse.data + .filter((flow) => flow.total_flow_usd !== null && flow.total_flow_usd !== undefined) + .slice(0, 50); + + if (flowsWithValues.length > 0) { + flowsWithValues.forEach((flow) => { + expectValidNumber(flow.total_flow_usd!); + expect(isNaN(flow.total_flow_usd!)).toBe(false); + expect(isFinite(flow.total_flow_usd!)).toBe(true); + }); + } + }); + + it('should have reasonable flow magnitudes', () => { + const flowsWithValues = flowsResponse.data + .filter((flow) => flow.total_flow_usd !== null && flow.total_flow_usd !== undefined) + .slice(0, 50); + + if (flowsWithValues.length > 0) { + flowsWithValues.forEach((flow) => { + const absFlow = Math.abs(flow.total_flow_usd!); + // Flow should be less than $100 billion + expect(absFlow).toBeLessThan(100_000_000_000); + }); + } + }); + }); + + describe('Asset-Specific Validation', () => { + it('should have Bitcoin flows', () => { + const bitcoinFlows = flowsResponse.data.filter( + (flow) => flow.gecko_id.toLowerCase() === 'bitcoin' + ); + expect(bitcoinFlows.length).toBeGreaterThan(0); + }); + + it('should have aggregate flow statistics for Bitcoin', () => { + const bitcoinFlows = flowsResponse.data + .filter((flow) => flow.gecko_id.toLowerCase() === 'bitcoin') + .filter((flow) => flow.total_flow_usd !== null && flow.total_flow_usd !== undefined); + + if (bitcoinFlows.length > 0) { + const totalFlow = bitcoinFlows.reduce((sum, flow) => sum + flow.total_flow_usd!, 0); + const avgFlow = totalFlow / bitcoinFlows.length; + + console.log(`Bitcoin flows: ${bitcoinFlows.length} entries, total: $${totalFlow.toLocaleString()}, avg: $${avgFlow.toLocaleString()}`); + + expect(isNaN(totalFlow)).toBe(false); + expect(isFinite(totalFlow)).toBe(true); + } + }); + + it('should have Ethereum flows if available', () => { + const ethereumFlows = flowsResponse.data.filter( + (flow) => flow.gecko_id.toLowerCase() === 'ethereum' + ); + + console.log(`Found ${ethereumFlows.length} Ethereum flow entries`); + + if (ethereumFlows.length > 0) { + const validFlows = ethereumFlows.filter( + (flow) => flow.total_flow_usd !== null && flow.total_flow_usd !== undefined + ); + + if (validFlows.length > 0) { + const totalFlow = validFlows.reduce((sum, flow) => sum + flow.total_flow_usd!, 0); + console.log(`Ethereum total flow: $${totalFlow.toLocaleString()}`); + } + } + }); + }); + + describe('Time Series Validation', () => { + it('should have continuous date coverage for major assets', () => { + const bitcoinFlows = flowsResponse.data + .filter((flow) => flow.gecko_id.toLowerCase() === 'bitcoin') + .sort((a, b) => new Date(a.day).getTime() - new Date(b.day).getTime()); + + if (bitcoinFlows.length > 10) { + // Check for gaps larger than 7 days + let largeGaps = 0; + for (let i = 1; i < Math.min(bitcoinFlows.length, 30); i++) { + const prevDate = new Date(bitcoinFlows[i - 1].day); + const currDate = new Date(bitcoinFlows[i].day); + const gapDays = (currDate.getTime() - prevDate.getTime()) / (86400 * 1000); + + if (gapDays > 7) { + largeGaps++; + } + } + + // Should have mostly continuous data (allow a few gaps) + const totalPairs = Math.min(bitcoinFlows.length - 1, 29); + expect(largeGaps / totalPairs).toBeLessThan(0.3); + } + }); + }); +}); + diff --git a/defi/api-tests/src/etfs/schemas.ts b/defi/api-tests/src/etfs/schemas.ts new file mode 100644 index 0000000000..40745a4b9d --- /dev/null +++ b/defi/api-tests/src/etfs/schemas.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +// ETF snapshot item schema +export const etfSnapshotSchema = z.object({ + ticker: z.string(), + timestamp: z.number(), + asset: z.string(), + issuer: z.string(), + etf_name: z.string(), + custodian: z.string().optional().nullable(), + pct_fee: z.number().optional().nullable(), + url: z.string().optional().nullable(), + flows: z.union([z.number(), z.null()]).optional(), + aum: z.union([z.number(), z.null()]).optional(), + volume: z.union([z.number(), z.null()]).optional(), +}); + +// ETF snapshot response schema (array) +export const etfSnapshotResponseSchema = z.array(etfSnapshotSchema); + +// ETF flow item schema +export const etfFlowSchema = z.object({ + gecko_id: z.string(), + day: z.string(), // ISO date string + total_flow_usd: z.union([z.number(), z.null()]), +}); + +// ETF flows response schema (array) +export const etfFlowsResponseSchema = z.array(etfFlowSchema); + diff --git a/defi/api-tests/src/etfs/snapshot.test.ts b/defi/api-tests/src/etfs/snapshot.test.ts new file mode 100644 index 0000000000..76d47e55e3 --- /dev/null +++ b/defi/api-tests/src/etfs/snapshot.test.ts @@ -0,0 +1,186 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { ETFSnapshotResponse, isETFSnapshotResponse } from './types'; +import { etfSnapshotResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, + expectValidTimestamp, +} from '../../utils/testHelpers'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.ETFS.BASE_URL); + +describe('ETFs API - Snapshot', () => { + let snapshotResponse: ApiResponse; + + beforeAll(async () => { + snapshotResponse = await apiClient.get(endpoints.ETFS.SNAPSHOT); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(snapshotResponse); + expect(isETFSnapshotResponse(snapshotResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = etfSnapshotResponseSchema.safeParse(snapshotResponse.data); + if (!result.success) { + console.log('Validation errors:', JSON.stringify(result.error.issues.slice(0, 10), null, 2)); + } + expect(result.success).toBe(true); + }); + + it('should return a non-empty array', () => { + expect(Array.isArray(snapshotResponse.data)).toBe(true); + expect(snapshotResponse.data.length).toBeGreaterThan(0); + }); + + it('should have minimum expected ETFs', () => { + expect(snapshotResponse.data.length).toBeGreaterThan(5); + }); + }); + + describe('Data Quality Validation', () => { + it('should have ETFs with valid timestamps', () => { + snapshotResponse.data.slice(0, 20).forEach((etf) => { + expectValidTimestamp(etf.timestamp); + expect(etf.timestamp).toBeGreaterThan(1600000000); // After Sept 2020 + }); + }); + + it('should have recent timestamp data', () => { + const timestamps = snapshotResponse.data.map((etf) => etf.timestamp); + const maxTimestamp = Math.max(...timestamps); + const nowInSeconds = Math.floor(Date.now() / 1000); + const ageInSeconds = nowInSeconds - maxTimestamp; + + // Data should be less than 7 days old + expect(ageInSeconds).toBeLessThan(86400 * 7); + }); + + it('should have ETFs for different assets', () => { + const assets = new Set(snapshotResponse.data.map((etf) => etf.asset)); + expect(assets.size).toBeGreaterThan(1); + }); + + it('should have ETFs from different issuers', () => { + const issuers = new Set(snapshotResponse.data.map((etf) => etf.issuer)); + expect(issuers.size).toBeGreaterThan(1); + }); + + it('should have unique tickers', () => { + const tickers = snapshotResponse.data.map((etf) => etf.ticker); + const uniqueTickers = new Set(tickers); + expect(uniqueTickers.size).toBe(tickers.length); + }); + }); + + describe('ETF Item Validation', () => { + it('should have required fields in all ETFs', () => { + snapshotResponse.data.slice(0, 20).forEach((etf) => { + expect(etf).toHaveProperty('ticker'); + expect(etf).toHaveProperty('timestamp'); + expect(etf).toHaveProperty('asset'); + expect(etf).toHaveProperty('issuer'); + expect(etf).toHaveProperty('etf_name'); + + expect(typeof etf.ticker).toBe('string'); + expect(etf.ticker.length).toBeGreaterThan(0); + expect(typeof etf.asset).toBe('string'); + expect(etf.asset.length).toBeGreaterThan(0); + expect(typeof etf.issuer).toBe('string'); + expect(etf.issuer.length).toBeGreaterThan(0); + expect(typeof etf.etf_name).toBe('string'); + expect(etf.etf_name.length).toBeGreaterThan(0); + }); + }); + + it('should have valid fee percentages when present', () => { + const etfsWithFees = snapshotResponse.data + .filter((etf) => etf.pct_fee !== null && etf.pct_fee !== undefined) + .slice(0, 20); + + if (etfsWithFees.length > 0) { + etfsWithFees.forEach((etf) => { + expectValidNumber(etf.pct_fee!); + expectNonNegativeNumber(etf.pct_fee!); + expect(etf.pct_fee).toBeGreaterThanOrEqual(0); + expect(etf.pct_fee).toBeLessThan(10); // Fee should be less than 10% + }); + } + }); + + it('should have valid AUM when present', () => { + const etfsWithAUM = snapshotResponse.data + .filter((etf) => etf.aum !== null && etf.aum !== undefined) + .slice(0, 20); + + if (etfsWithAUM.length > 0) { + etfsWithAUM.forEach((etf) => { + expectValidNumber(etf.aum!); + expectNonNegativeNumber(etf.aum!); + }); + } + }); + + it('should have valid volume when present', () => { + const etfsWithVolume = snapshotResponse.data + .filter((etf) => etf.volume !== null && etf.volume !== undefined) + .slice(0, 20); + + if (etfsWithVolume.length > 0) { + etfsWithVolume.forEach((etf) => { + expectValidNumber(etf.volume!); + expectNonNegativeNumber(etf.volume!); + }); + } + }); + + it('should have valid flows when present', () => { + const etfsWithFlows = snapshotResponse.data + .filter((etf) => etf.flows !== null && etf.flows !== undefined) + .slice(0, 20); + + if (etfsWithFlows.length > 0) { + etfsWithFlows.forEach((etf) => { + expectValidNumber(etf.flows!); + }); + } + }); + + it('should have valid URLs when present', () => { + const etfsWithURLs = snapshotResponse.data + .filter((etf) => etf.url && etf.url !== null) + .slice(0, 20); + + if (etfsWithURLs.length > 0) { + etfsWithURLs.forEach((etf) => { + expect(typeof etf.url).toBe('string'); + expect(etf.url!.length).toBeGreaterThan(0); + expect(etf.url).toMatch(/^https?:\/\//); + }); + } + }); + }); + + describe('Asset-Specific Validation', () => { + it('should have Bitcoin ETFs', () => { + const bitcoinETFs = snapshotResponse.data.filter( + (etf) => etf.asset.toLowerCase() === 'bitcoin' + ); + expect(bitcoinETFs.length).toBeGreaterThan(0); + }); + + it('should have Ethereum ETFs if available', () => { + const ethereumETFs = snapshotResponse.data.filter( + (etf) => etf.asset.toLowerCase() === 'ethereum' + ); + // Ethereum ETFs may or may not exist, so just log it + console.log(`Found ${ethereumETFs.length} Ethereum ETFs`); + }); + }); +}); + diff --git a/defi/api-tests/src/etfs/types.ts b/defi/api-tests/src/etfs/types.ts new file mode 100644 index 0000000000..90a0327cc3 --- /dev/null +++ b/defi/api-tests/src/etfs/types.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import { + etfSnapshotSchema, + etfSnapshotResponseSchema, + etfFlowSchema, + etfFlowsResponseSchema, +} from './schemas'; + +// Inferred types +export type ETFSnapshot = z.infer; +export type ETFSnapshotResponse = z.infer; +export type ETFFlow = z.infer; +export type ETFFlowsResponse = z.infer; + +// Type guards +export function isETFSnapshotResponse(data: any): data is ETFSnapshotResponse { + return Array.isArray(data) && data.length > 0 && 'ticker' in data[0]; +} + +export function isETFFlowsResponse(data: any): data is ETFFlowsResponse { + return Array.isArray(data) && data.length > 0 && 'gecko_id' in data[0]; +} + diff --git a/defi/api-tests/src/fees/overviewFees.test.ts b/defi/api-tests/src/fees/overviewFees.test.ts new file mode 100644 index 0000000000..6a21713101 --- /dev/null +++ b/defi/api-tests/src/fees/overviewFees.test.ts @@ -0,0 +1,244 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { FeeOverviewResponse, isFeeOverviewResponse } from './types'; +import { feeOverviewResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, + expectFreshData, +} from '../../utils/testHelpers'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.FEES.BASE_URL); + +describe('Fees API - Overview Fees', () => { + let overviewResponse: ApiResponse; + let chainResponse: ApiResponse; + + beforeAll(async () => { + const [r1, r2] = await Promise.all([ + apiClient.get(endpoints.FEES.OVERVIEW_FEES), + apiClient.get(endpoints.FEES.OVERVIEW_FEES_CHAIN('ethereum')), + ]); + + overviewResponse = r1; + chainResponse = r2; + }, 30000); + + describe('All Fees Overview', () => { + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(overviewResponse); + expect(isFeeOverviewResponse(overviewResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = feeOverviewResponseSchema.safeParse(overviewResponse.data); + if (!result.success) { + console.log('Validation errors:', JSON.stringify(result.error.issues.slice(0, 10), null, 2)); + } + expect(result.success).toBe(true); + }); + + it('should have total data chart with data', () => { + expect(Array.isArray(overviewResponse.data.totalDataChart)).toBe(true); + expect(overviewResponse.data.totalDataChart.length).toBeGreaterThan(100); + }); + }); + + describe('Data Quality Validation', () => { + it('should have fresh data in chart', () => { + const timestamps = overviewResponse.data.totalDataChart.map((point) => point[0]); + expectFreshData(timestamps, 86400 * 2); // 2 days + }); + + it('should have valid total metrics when present', () => { + if (overviewResponse.data.total24h !== null && overviewResponse.data.total24h !== undefined) { + expectValidNumber(overviewResponse.data.total24h); + expectNonNegativeNumber(overviewResponse.data.total24h); + } + + if (overviewResponse.data.total7d !== null && overviewResponse.data.total7d !== undefined) { + expectValidNumber(overviewResponse.data.total7d); + expectNonNegativeNumber(overviewResponse.data.total7d); + } + + if (overviewResponse.data.total30d !== null && overviewResponse.data.total30d !== undefined) { + expectValidNumber(overviewResponse.data.total30d); + expectNonNegativeNumber(overviewResponse.data.total30d); + } + }); + + it('should have chronologically ordered chart data', () => { + const timestamps = overviewResponse.data.totalDataChart.map((point) => point[0]); + + for (let i = 1; i < timestamps.length; i++) { + expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]); + } + }); + + it('should have protocols in response', () => { + // Count all non-standard fields as potential protocols + const excludedKeys = ['totalDataChart', 'totalDataChartBreakdown', 'breakdown24h', 'breakdown30d', 'chain', 'allChains', 'total24h', 'total48hto24h', 'total7d', 'total14dto7d', 'total30d', 'total60dto30d', 'total1y', 'totalAllTime', 'total7DaysAgo', 'total30DaysAgo', 'change_1d', 'change_7d', 'change_1m', 'change_7dover7d', 'change_30dover30d', 'protocols']; + const protocolKeys = Object.keys(overviewResponse.data).filter( + key => !excludedKeys.includes(key) + ); + // Response should have data structure (may have protocols at root level) + expect(protocolKeys.length).toBeGreaterThanOrEqual(0); + }); + + it('should have multiple chains when allChains is present', () => { + if (overviewResponse.data.allChains) { + expect(overviewResponse.data.allChains.length).toBeGreaterThan(5); + } + }); + }); + + describe('Protocol Item Validation', () => { + it('should have valid protocol data at root level', () => { + const excludedKeys = ['totalDataChart', 'totalDataChartBreakdown', 'breakdown24h', 'breakdown30d', 'chain', 'allChains', 'total24h', 'total48hto24h', 'total7d', 'total14dto7d', 'total30d', 'total60dto30d', 'total1y', 'totalAllTime', 'total7DaysAgo', 'total30DaysAgo', 'change_1d', 'change_7d', 'change_1m', 'change_7dover7d', 'change_30dover30d', 'protocols']; + const protocolKeys = Object.keys(overviewResponse.data).filter( + key => !excludedKeys.includes(key) + ); + + // Check first few protocols + protocolKeys.slice(0, 10).forEach((key) => { + const protocol = (overviewResponse.data as any)[key]; + if (protocol && typeof protocol === 'object' && protocol.name) { + expect(typeof protocol.name).toBe('string'); + expect(protocol.name.length).toBeGreaterThan(0); + } + }); + }); + + it('should have valid fee metrics when present', () => { + const excludedKeys = ['totalDataChart', 'totalDataChartBreakdown', 'breakdown24h', 'breakdown30d', 'chain', 'allChains', 'total24h', 'total48hto24h', 'total7d', 'total14dto7d', 'total30d', 'total60dto30d', 'total1y', 'totalAllTime', 'total7DaysAgo', 'total30DaysAgo', 'change_1d', 'change_7d', 'change_1m', 'change_7dover7d', 'change_30dover30d', 'protocols']; + const protocolKeys = Object.keys(overviewResponse.data).filter( + key => !excludedKeys.includes(key) + ); + + const protocolsWithFees = protocolKeys + .map(key => (overviewResponse.data as any)[key]) + .filter((p) => p && typeof p === 'object' && p.total24h !== null && p.total24h !== undefined) + .slice(0, 20); + + if (protocolsWithFees.length > 0) { + protocolsWithFees.forEach((protocol) => { + const fees = Number(protocol.total24h); + expectValidNumber(fees); + expectNonNegativeNumber(fees); + }); + } + }); + + it('should have valid chains arrays when present', () => { + const excludedKeys = ['totalDataChart', 'totalDataChartBreakdown', 'breakdown24h', 'breakdown30d', 'chain', 'allChains', 'total24h', 'total48hto24h', 'total7d', 'total14dto7d', 'total30d', 'total60dto30d', 'total1y', 'totalAllTime', 'total7DaysAgo', 'total30DaysAgo', 'change_1d', 'change_7d', 'change_1m', 'change_7dover7d', 'change_30dover30d', 'protocols']; + const protocolKeys = Object.keys(overviewResponse.data).filter( + key => !excludedKeys.includes(key) + ); + + const protocolsWithChains = protocolKeys + .map(key => (overviewResponse.data as any)[key]) + .filter((p) => p && typeof p === 'object' && p.chains && p.chains.length > 0) + .slice(0, 20); + + if (protocolsWithChains.length > 0) { + protocolsWithChains.forEach((protocol) => { + expect(Array.isArray(protocol.chains)).toBe(true); + expect(protocol.chains.length).toBeGreaterThan(0); + protocol.chains.forEach((chain: string) => { + expect(typeof chain).toBe('string'); + expect(chain.length).toBeGreaterThan(0); + }); + }); + } + }); + }); + + describe('Chart Data Validation', () => { + it('should have valid chart data points', () => { + overviewResponse.data.totalDataChart.slice(0, 50).forEach((point) => { + expect(Array.isArray(point)).toBe(true); + expect(point.length).toBe(2); + + // Timestamp + expectValidNumber(point[0]); + expect(point[0]).toBeGreaterThan(1400000000); // After May 2014 + + // Value + const value = typeof point[1] === 'string' ? Number(point[1]) : point[1]; + expectValidNumber(value); + expectNonNegativeNumber(value); + }); + }); + + it('should have reasonable time coverage', () => { + const timestamps = overviewResponse.data.totalDataChart.map((point) => point[0]); + const minTime = Math.min(...timestamps); + const maxTime = Math.max(...timestamps); + const spanDays = (maxTime - minTime) / 86400; + + expect(spanDays).toBeGreaterThan(30); // At least 30 days + }); + }); + }); + + describe('Specific Chain Fees Overview', () => { + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(chainResponse); + expect(isFeeOverviewResponse(chainResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = feeOverviewResponseSchema.safeParse(chainResponse.data); + if (!result.success) { + console.log('Validation errors:', JSON.stringify(result.error.issues.slice(0, 10), null, 2)); + } + expect(result.success).toBe(true); + }); + + it('should have chain field set', () => { + expect(chainResponse.data.chain).toBe('Ethereum'); + }); + }); + + describe('Data Quality Validation', () => { + it('should have protocols with data', () => { + const excludedKeys = ['totalDataChart', 'totalDataChartBreakdown', 'breakdown24h', 'breakdown30d', 'chain', 'allChains', 'total24h', 'total48hto24h', 'total7d', 'total14dto7d', 'total30d', 'total60dto30d', 'total1y', 'totalAllTime', 'total7DaysAgo', 'total30DaysAgo', 'change_1d', 'change_7d', 'change_1m', 'change_7dover7d', 'change_30dover30d', 'protocols']; + const protocolKeys = Object.keys(chainResponse.data).filter( + key => !excludedKeys.includes(key) + ); + // Chain-specific responses may have fewer or no protocols at root level + expect(protocolKeys.length).toBeGreaterThanOrEqual(0); + }); + + it('should have fresh chart data', () => { + const timestamps = chainResponse.data.totalDataChart.map((point) => point[0]); + expectFreshData(timestamps, 86400 * 2); // 2 days + }); + + it('should have protocols for the correct chain when chains specified', () => { + const excludedKeys = ['totalDataChart', 'totalDataChartBreakdown', 'breakdown24h', 'breakdown30d', 'chain', 'allChains', 'total24h', 'total48hto24h', 'total7d', 'total14dto7d', 'total30d', 'total60dto30d', 'total1y', 'totalAllTime', 'total7DaysAgo', 'total30DaysAgo', 'change_1d', 'change_7d', 'change_1m', 'change_7dover7d', 'change_30dover30d', 'protocols']; + const protocolKeys = Object.keys(chainResponse.data).filter( + key => !excludedKeys.includes(key) + ); + + const protocolsWithChains = protocolKeys + .map(key => (chainResponse.data as any)[key]) + .filter((p) => p && typeof p === 'object' && p.chains && p.chains.length > 0) + .slice(0, 10); + + if (protocolsWithChains.length > 0) { + // At least some protocols should include Ethereum + const hasEthereumProtocols = protocolsWithChains.some( + (p) => p.chains.includes('Ethereum') + ); + expect(hasEthereumProtocols).toBe(true); + } + }); + }); + }); +}); + diff --git a/defi/api-tests/src/fees/schemas.ts b/defi/api-tests/src/fees/schemas.ts new file mode 100644 index 0000000000..0b66a7a419 --- /dev/null +++ b/defi/api-tests/src/fees/schemas.ts @@ -0,0 +1,124 @@ +import { z } from 'zod'; + +// Protocol schema for overview and summary responses +export const feeProtocolSchema = z.object({ + name: z.string(), + defillamaId: z.string().optional().nullable(), + disabled: z.boolean().optional().nullable(), + displayName: z.string().optional().nullable(), + module: z.string(), + category: z.string().optional().nullable(), + logo: z.string().optional().nullable(), + change_1d: z.union([z.number(), z.null()]).optional(), + change_7d: z.union([z.number(), z.null()]).optional(), + change_1m: z.union([z.number(), z.null()]).optional(), + change_7dover7d: z.union([z.number(), z.null()]).optional(), + change_30dover30d: z.union([z.number(), z.null()]).optional(), + total24h: z.union([z.number(), z.null()]).optional(), + total48hto24h: z.union([z.number(), z.null()]).optional(), + total7d: z.union([z.number(), z.null()]).optional(), + total14dto7d: z.union([z.number(), z.null()]).optional(), + total30d: z.union([z.number(), z.null()]).optional(), + total60dto30d: z.union([z.number(), z.null()]).optional(), + total1y: z.union([z.number(), z.null()]).optional(), + totalAllTime: z.union([z.number(), z.null()]).optional(), + average1y: z.union([z.number(), z.null()]).optional(), + monthlyAverage1y: z.union([z.number(), z.null()]).optional(), + total7DaysAgo: z.union([z.number(), z.null()]).optional(), + total30DaysAgo: z.union([z.number(), z.null()]).optional(), + chains: z.array(z.string()).optional(), + protocolType: z.string().optional().nullable(), + methodologyURL: z.string().optional().nullable(), + methodology: z.union([z.string(), z.record(z.string(), z.string())]).optional().nullable(), + latestFetchIsOk: z.boolean().optional(), + breakdown24h: z.record(z.string(), z.any()).optional().nullable(), + breakdown30d: z.record(z.string(), z.any()).optional().nullable(), + dailyFees: z.union([z.number(), z.string()]).optional().nullable(), + dailyRevenue: z.union([z.number(), z.string()]).optional().nullable(), + dailyHoldersRevenue: z.union([z.number(), z.string()]).optional().nullable(), + dailySupplySideRevenue: z.union([z.number(), z.string()]).optional().nullable(), + dailyProtocolRevenue: z.union([z.number(), z.string()]).optional().nullable(), + dailyUserFees: z.union([z.number(), z.string()]).optional().nullable(), + id: z.string().optional().nullable(), + slug: z.string().optional().nullable(), + parentProtocol: z.string().optional().nullable(), +}); + +// Data chart point schema [timestamp, value] +export const dataChartPointSchema = z.tuple([ + z.number(), // timestamp + z.union([z.number(), z.string()]) // value (can be number or string) +]); + +// Overview response schema (for /overview/fees) +export const feeOverviewResponseSchema = z.object({ + totalDataChart: z.array(dataChartPointSchema), + totalDataChartBreakdown: z.array(z.array(z.any())).optional().nullable(), + breakdown24h: z.record(z.string(), z.any()).optional().nullable(), + breakdown30d: z.record(z.string(), z.any()).optional().nullable(), + chain: z.string().optional().nullable(), + allChains: z.array(z.string()).optional(), + total24h: z.union([z.number(), z.null()]).optional(), + total48hto24h: z.union([z.number(), z.null()]).optional(), + total7d: z.union([z.number(), z.null()]).optional(), + total14dto7d: z.union([z.number(), z.null()]).optional(), + total30d: z.union([z.number(), z.null()]).optional(), + total60dto30d: z.union([z.number(), z.null()]).optional(), + total1y: z.union([z.number(), z.null()]).optional(), + totalAllTime: z.union([z.number(), z.null()]).optional(), + total7DaysAgo: z.union([z.number(), z.null()]).optional(), + total30DaysAgo: z.union([z.number(), z.null()]).optional(), + change_1d: z.union([z.number(), z.null()]).optional(), + change_7d: z.union([z.number(), z.null()]).optional(), + change_1m: z.union([z.number(), z.null()]).optional(), + change_7dover7d: z.union([z.number(), z.null()]).optional(), + change_30dover30d: z.union([z.number(), z.null()]).optional(), + protocols: z.array(feeProtocolSchema).optional(), +}).catchall(feeProtocolSchema); // Allow protocols to be at the root level + +// Summary protocol response schema (for /summary/fees/{protocol}) +export const feeSummaryResponseSchema = z.object({ + id: z.string(), + name: z.string(), + url: z.string().optional().nullable(), + description: z.string().optional().nullable(), + logo: z.string().optional().nullable(), + gecko_id: z.string().optional().nullable(), + cmcId: z.string().optional().nullable(), + chains: z.array(z.string()), + twitter: z.string().optional().nullable(), + treasury: z.string().optional().nullable(), + governanceID: z.array(z.string()).optional().nullable(), + github: z.array(z.string()).optional().nullable(), + symbol: z.string().optional().nullable(), + address: z.string().optional().nullable(), + tokenAddress: z.string().optional().nullable(), + slug: z.string().optional().nullable(), + module: z.string().optional().nullable(), + category: z.string().optional().nullable(), + methodologyURL: z.string().optional().nullable(), + methodology: z.union([z.string(), z.record(z.string(), z.string())]).optional().nullable(), + protocolType: z.string().optional().nullable(), + disabled: z.boolean().optional().nullable(), + displayName: z.string().optional().nullable(), + latestFetchIsOk: z.boolean().optional(), + total24h: z.union([z.number(), z.null()]).optional(), + total48hto24h: z.union([z.number(), z.null()]).optional(), + total7d: z.union([z.number(), z.null()]).optional(), + total30d: z.union([z.number(), z.null()]).optional(), + total1y: z.union([z.number(), z.null()]).optional(), + totalAllTime: z.union([z.number(), z.null()]).optional(), + change_1d: z.union([z.number(), z.null()]).optional(), + change_7d: z.union([z.number(), z.null()]).optional(), + change_1m: z.union([z.number(), z.null()]).optional(), + change_7dover7d: z.union([z.number(), z.null()]).optional(), + change_30dover30d: z.union([z.number(), z.null()]).optional(), + totalDataChart: z.array(dataChartPointSchema).optional(), + totalDataChartBreakdown: z.array(z.array(z.any())).optional().nullable(), + chainBreakdown: z.record(z.string(), z.any()).optional().nullable(), + versionKey: z.string().optional().nullable(), + parentProtocol: z.string().optional().nullable(), + linkedProtocols: z.array(z.string()).optional().nullable(), + childProtocols: z.array(z.any()).optional().nullable(), +}); + diff --git a/defi/api-tests/src/fees/summaryFees.test.ts b/defi/api-tests/src/fees/summaryFees.test.ts new file mode 100644 index 0000000000..6c7f8fe8ee --- /dev/null +++ b/defi/api-tests/src/fees/summaryFees.test.ts @@ -0,0 +1,267 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { FeeSummaryResponse, isFeeSummaryResponse } from './types'; +import { feeSummaryResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, + expectFreshData, +} from '../../utils/testHelpers'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.FEES.BASE_URL); + +describe('Fees API - Summary Fees', () => { + // Test with popular protocols + const testProtocols = ['uniswap', 'aave', 'lido']; + + const responses: Record> = {}; + + beforeAll(async () => { + const results = await Promise.all( + testProtocols.map((protocol) => + apiClient.get(endpoints.FEES.SUMMARY_FEES(protocol)) + ) + ); + + testProtocols.forEach((protocol, index) => { + responses[protocol] = results[index]; + }); + }, 30000); + + describe.each(testProtocols)('Protocol: %s', (protocol) => { + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(responses[protocol]); + expect(isFeeSummaryResponse(responses[protocol].data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = feeSummaryResponseSchema.safeParse(responses[protocol].data); + if (!result.success) { + console.log('Validation errors:', JSON.stringify(result.error.issues.slice(0, 10), null, 2)); + } + expect(result.success).toBe(true); + }); + + it('should have required fields', () => { + const data = responses[protocol].data; + expect(data).toHaveProperty('id'); + expect(data).toHaveProperty('name'); + expect(data).toHaveProperty('chains'); + expect(typeof data.id).toBe('string'); + expect(typeof data.name).toBe('string'); + expect(Array.isArray(data.chains)).toBe(true); + }); + }); + + describe('Data Quality Validation', () => { + it('should have non-empty protocol name', () => { + expect(responses[protocol].data.name.length).toBeGreaterThan(0); + }); + + it('should have at least one chain', () => { + expect(responses[protocol].data.chains.length).toBeGreaterThan(0); + }); + + it('should have valid chain names', () => { + responses[protocol].data.chains.forEach((chain) => { + expect(typeof chain).toBe('string'); + expect(chain.length).toBeGreaterThan(0); + }); + }); + + it('should have valid fee metrics when present', () => { + const data = responses[protocol].data; + + if (data.total24h !== null && data.total24h !== undefined) { + expectValidNumber(data.total24h); + expectNonNegativeNumber(data.total24h); + } + + if (data.total7d !== null && data.total7d !== undefined) { + expectValidNumber(data.total7d); + expectNonNegativeNumber(data.total7d); + } + + if (data.total30d !== null && data.total30d !== undefined) { + expectValidNumber(data.total30d); + expectNonNegativeNumber(data.total30d); + } + + if (data.total1y !== null && data.total1y !== undefined) { + expectValidNumber(data.total1y); + expectNonNegativeNumber(data.total1y); + } + + if (data.totalAllTime !== null && data.totalAllTime !== undefined) { + expectValidNumber(data.totalAllTime); + expectNonNegativeNumber(data.totalAllTime); + } + }); + + it('should have valid change percentages when present', () => { + const data = responses[protocol].data; + + if (data.change_1d !== null && data.change_1d !== undefined) { + expectValidNumber(data.change_1d); + expect(isNaN(data.change_1d)).toBe(false); + expect(isFinite(data.change_1d)).toBe(true); + } + + if (data.change_7d !== null && data.change_7d !== undefined) { + expectValidNumber(data.change_7d); + expect(isNaN(data.change_7d)).toBe(false); + expect(isFinite(data.change_7d)).toBe(true); + } + + if (data.change_7dover7d !== null && data.change_7dover7d !== undefined) { + expectValidNumber(data.change_7dover7d); + expect(isNaN(data.change_7dover7d)).toBe(false); + expect(isFinite(data.change_7dover7d)).toBe(true); + } + + if (data.change_30dover30d !== null && data.change_30dover30d !== undefined) { + expectValidNumber(data.change_30dover30d); + expect(isNaN(data.change_30dover30d)).toBe(false); + expect(isFinite(data.change_30dover30d)).toBe(true); + } + }); + }); + + describe('Chart Data Validation', () => { + it('should have chart data when present', () => { + const data = responses[protocol].data; + + if (data.totalDataChart && data.totalDataChart.length > 0) { + expect(Array.isArray(data.totalDataChart)).toBe(true); + expect(data.totalDataChart.length).toBeGreaterThan(10); + + // Check a few data points + data.totalDataChart.slice(0, 20).forEach((point) => { + expect(Array.isArray(point)).toBe(true); + expect(point.length).toBe(2); + + // Timestamp + expectValidNumber(point[0]); + expect(point[0]).toBeGreaterThan(1400000000); // After May 2014 + + // Value + const value = typeof point[1] === 'string' ? Number(point[1]) : point[1]; + expectValidNumber(value); + expectNonNegativeNumber(value); + }); + } + }); + + it('should have fresh chart data when present', () => { + const data = responses[protocol].data; + + if (data.totalDataChart && data.totalDataChart.length > 0) { + const timestamps = data.totalDataChart.map((point) => point[0]); + expectFreshData(timestamps, 86400 * 3); // 3 days + } + }); + + it('should have chronologically ordered chart data', () => { + const data = responses[protocol].data; + + if (data.totalDataChart && data.totalDataChart.length > 1) { + const timestamps = data.totalDataChart.map((point) => point[0]); + + for (let i = 1; i < timestamps.length; i++) { + expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]); + } + } + }); + }); + + describe('Metadata Validation', () => { + it('should have valid optional metadata when present', () => { + const data = responses[protocol].data; + + if (data.logo) { + expect(typeof data.logo).toBe('string'); + expect(data.logo.length).toBeGreaterThan(0); + } + + if (data.url) { + expect(typeof data.url).toBe('string'); + expect(data.url.length).toBeGreaterThan(0); + } + + if (data.description) { + expect(typeof data.description).toBe('string'); + expect(data.description.length).toBeGreaterThan(0); + } + + if (data.twitter) { + expect(typeof data.twitter).toBe('string'); + } + }); + + it('should have valid category when present', () => { + const data = responses[protocol].data; + + if (data.category) { + expect(typeof data.category).toBe('string'); + expect(data.category.length).toBeGreaterThan(0); + } + }); + + it('should have valid methodology when present', () => { + const data = responses[protocol].data; + + if (data.methodology) { + expect( + typeof data.methodology === 'string' || + typeof data.methodology === 'object' + ).toBe(true); + } + + if (data.methodologyURL) { + expect(typeof data.methodologyURL).toBe('string'); + expect(data.methodologyURL.length).toBeGreaterThan(0); + } + }); + }); + }); + + describe('Cross-Protocol Comparison', () => { + it('should have different protocol IDs', () => { + const ids = testProtocols.map((protocol) => responses[protocol].data.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(testProtocols.length); + }); + + it('should have different protocol names', () => { + const names = testProtocols.map((protocol) => responses[protocol].data.name); + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(testProtocols.length); + }); + + it('should have varying fee metrics', () => { + const fees = testProtocols + .map((protocol) => responses[protocol].data.total24h) + .filter((fee) => fee !== null && fee !== undefined); + + if (fees.length > 1) { + const uniqueFees = new Set(fees.map((f) => Number(f))); + expect(uniqueFees.size).toBeGreaterThan(1); + } + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent protocol gracefully', async () => { + const response = await apiClient.get( + endpoints.FEES.SUMMARY_FEES('nonexistentprotocol123456') + ); + + // Should return 404 or similar error status + expect(response.status).toBeGreaterThanOrEqual(400); + }); + }); +}); + diff --git a/defi/api-tests/src/fees/types.ts b/defi/api-tests/src/fees/types.ts new file mode 100644 index 0000000000..1459485b95 --- /dev/null +++ b/defi/api-tests/src/fees/types.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { + feeProtocolSchema, + dataChartPointSchema, + feeOverviewResponseSchema, + feeSummaryResponseSchema, +} from './schemas'; + +// Inferred types +export type FeeProtocol = z.infer; +export type DataChartPoint = z.infer; +export type FeeOverviewResponse = z.infer; +export type FeeSummaryResponse = z.infer; + +// Type guards +export function isFeeOverviewResponse(data: any): data is FeeOverviewResponse { + return ( + data && + typeof data === 'object' && + Array.isArray(data.totalDataChart) + ); +} + +export function isFeeSummaryResponse(data: any): data is FeeSummaryResponse { + return ( + data && + typeof data === 'object' && + typeof data.id === 'string' && + typeof data.name === 'string' && + Array.isArray(data.chains) + ); +} + diff --git a/defi/api-tests/src/main-page/categories.test.ts b/defi/api-tests/src/main-page/categories.test.ts new file mode 100644 index 0000000000..a0abfb63a6 --- /dev/null +++ b/defi/api-tests/src/main-page/categories.test.ts @@ -0,0 +1,164 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { CategoriesResponse, isCategoriesResponse } from './types'; +import { categoriesResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.MAIN_PAGE.BASE_URL); + +describe('Main Page API - Categories', () => { + let categoriesResponse: ApiResponse; + + beforeAll(async () => { + categoriesResponse = await apiClient.get( + endpoints.MAIN_PAGE.CATEGORIES + ); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(categoriesResponse); + expect(isCategoriesResponse(categoriesResponse.data)).toBe(true); + expect(categoriesResponse.data).toHaveProperty('categories'); + expect(categoriesResponse.data).toHaveProperty('chart'); + expect(typeof categoriesResponse.data.categories).toBe('object'); + }); + + it('should validate against Zod schema', () => { + const result = validate( + categoriesResponse.data, + categoriesResponseSchema, + 'Categories' + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should have non-empty categories object', () => { + const categoryKeys = Object.keys(categoriesResponse.data.categories); + expect(categoryKeys.length).toBeGreaterThan(0); + }); + + it('should have minimum expected categories', () => { + const categoryKeys = Object.keys(categoriesResponse.data.categories); + expect(categoryKeys.length).toBeGreaterThan(10); + }); + }); + + describe('Category Data Validation', () => { + it('should have valid category names as keys', () => { + const categoryKeys = Object.keys(categoriesResponse.data.categories); + + categoryKeys.slice(0, 20).forEach((categoryName) => { + expect(typeof categoryName).toBe('string'); + expect(categoryName.length).toBeGreaterThan(0); + }); + }); + + it('should have arrays of protocols for each category', () => { + const categories = categoriesResponse.data.categories; + const categoryKeys = Object.keys(categories); + + categoryKeys.slice(0, 20).forEach((categoryName) => { + expect(Array.isArray(categories[categoryName])).toBe(true); + }); + }); + + it('should have protocol names as strings in each category array', () => { + const categories = categoriesResponse.data.categories; + const categoryKeys = Object.keys(categories); + + categoryKeys.slice(0, 10).forEach((categoryName) => { + const protocols = categories[categoryName]; + + if (protocols.length > 0) { + protocols.slice(0, 5).forEach((protocolName) => { + expect(typeof protocolName).toBe('string'); + expect(protocolName.length).toBeGreaterThan(0); + }); + } + }); + }); + + it('should have well-known categories', () => { + const categoryKeys = Object.keys(categoriesResponse.data.categories).map(k => k.toLowerCase()); + const wellKnownCategories = ['dexs', 'lending', 'bridge']; + + const foundCategories = wellKnownCategories.filter((name) => + categoryKeys.some((cName) => cName.toLowerCase().includes(name)) + ); + + expect(foundCategories.length).toBeGreaterThan(0); + }); + + it('should have categories with multiple protocols', () => { + const categories = categoriesResponse.data.categories; + const categoriesWithProtocols = Object.entries(categories) + .filter(([_, protocols]) => protocols.length > 1); + + expect(categoriesWithProtocols.length).toBeGreaterThan(0); + }); + }); + + describe('Chart Data Validation', () => { + it('should have chart data with timestamps', () => { + const chart = categoriesResponse.data.chart; + const timestamps = Object.keys(chart); + + expect(timestamps.length).toBeGreaterThan(0); + + timestamps.slice(0, 10).forEach((timestamp) => { + const ts = Number(timestamp); + expect(isNaN(ts)).toBe(false); + expect(ts).toBeGreaterThan(0); + }); + }); + + it('should have category data in chart entries', () => { + const chart = categoriesResponse.data.chart; + const timestamps = Object.keys(chart); + + if (timestamps.length > 0) { + const firstTimestamp = timestamps[0]; + const categoryData = chart[firstTimestamp]; + + expect(typeof categoryData).toBe('object'); + + const categoryKeys = Object.keys(categoryData); + expect(categoryKeys.length).toBeGreaterThan(0); + } + }); + + it('should have TVL data in chart category entries', () => { + const chart = categoriesResponse.data.chart; + const timestamps = Object.keys(chart); + + if (timestamps.length > 0) { + const firstTimestamp = timestamps[0]; + const categoryData = chart[firstTimestamp]; + const categoryKeys = Object.keys(categoryData); + + if (categoryKeys.length > 0) { + categoryKeys.slice(0, 5).forEach((categoryName) => { + expect(typeof categoryData[categoryName]).toBe('object'); + }); + } + } + }); + + it('should have chronologically ordered timestamps', () => { + const timestamps = Object.keys(categoriesResponse.data.chart).map(Number); + + if (timestamps.length > 1) { + const sortedTimestamps = [...timestamps].sort((a, b) => a - b); + expect(timestamps).toEqual(sortedTimestamps); + } + }); + }); +}); diff --git a/defi/api-tests/src/main-page/entities.test.ts b/defi/api-tests/src/main-page/entities.test.ts new file mode 100644 index 0000000000..dc2ee283e6 --- /dev/null +++ b/defi/api-tests/src/main-page/entities.test.ts @@ -0,0 +1,159 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { EntitiesResponse, isEntitiesResponse } from './types'; +import { entitiesResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectArrayResponse, + expectValidNumber, + expectNonNegativeNumber, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.MAIN_PAGE.BASE_URL); + +describe('Main Page API - Entities', () => { + let entitiesResponse: ApiResponse; + + beforeAll(async () => { + entitiesResponse = await apiClient.get( + endpoints.MAIN_PAGE.ENTITIES + ); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(entitiesResponse); + expectArrayResponse(entitiesResponse); + expect(isEntitiesResponse(entitiesResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate( + entitiesResponse.data, + entitiesResponseSchema, + 'Entities' + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should return a non-empty array', () => { + expect(entitiesResponse.data.length).toBeGreaterThan(0); + }); + }); + + describe('Entity Item Validation', () => { + it('should have required fields in all entities', () => { + entitiesResponse.data.slice(0, 20).forEach((entity) => { + expect(entity).toHaveProperty('name'); + expect(typeof entity.name).toBe('string'); + expect(entity.name.length).toBeGreaterThan(0); + }); + }); + + it('should have valid numeric fields when present', () => { + const entitiesWithData = entitiesResponse.data + .filter((e) => e.tvl !== undefined) + .slice(0, 20); + + expect(entitiesWithData.length).toBeGreaterThan(0); + + entitiesWithData.forEach((entity) => { + if (entity.protocols !== undefined) { + expectValidNumber(entity.protocols); + expectNonNegativeNumber(entity.protocols); + } + + if (entity.tvl !== undefined) { + expectValidNumber(entity.tvl); + expectNonNegativeNumber(entity.tvl); + } + }); + }); + + it('should have valid percentage change fields when present', () => { + const entitiesWithChange = entitiesResponse.data + .filter((e) => e.change_1d !== null && e.change_1d !== undefined) + .slice(0, 20); + + if (entitiesWithChange.length > 0) { + entitiesWithChange.forEach((entity) => { + if (entity.change_1h !== null && entity.change_1h !== undefined) { + expectValidNumber(entity.change_1h); + } + if (entity.change_1d !== null && entity.change_1d !== undefined) { + expectValidNumber(entity.change_1d); + } + if (entity.change_7d !== null && entity.change_7d !== undefined) { + expectValidNumber(entity.change_7d); + } + }); + } + }); + + it('should have unique entity names', () => { + const names = entitiesResponse.data.map((e) => e.name); + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(names.length); + }); + }); + + describe('Data Quality Validation', () => { + it('should have entities sorted by TVL in descending order', () => { + const entitiesWithTvl = entitiesResponse.data + .filter((e) => e.tvl !== undefined) + .slice(0, 50); + + if (entitiesWithTvl.length > 1) { + for (let i = 0; i < entitiesWithTvl.length - 1; i++) { + expect(entitiesWithTvl[i].tvl!).toBeGreaterThanOrEqual( + entitiesWithTvl[i + 1].tvl! + ); + } + } + }); + + it('should have reasonable TVL values', () => { + const topEntities = entitiesResponse.data + .filter((e) => e.tvl !== undefined) + .slice(0, 10); + + topEntities.forEach((entity) => { + if (entity.tvl !== undefined) { + expect(entity.tvl).toBeGreaterThan(0); + expect(entity.tvl).toBeLessThan(1_000_000_000_000); + } + }); + }); + + it('should have protocol count matching TVL presence', () => { + const entitiesWithTvl = entitiesResponse.data.filter( + (e) => e.tvl !== undefined && e.tvl > 0 + ); + + entitiesWithTvl.slice(0, 20).forEach((entity) => { + if (entity.protocols !== undefined) { + expect(entity.protocols).toBeGreaterThan(0); + } + }); + }); + + it('should have reasonable protocol counts', () => { + const entitiesWithProtocols = entitiesResponse.data + .filter((e) => e.protocols !== undefined) + .slice(0, 20); + + if (entitiesWithProtocols.length > 0) { + entitiesWithProtocols.forEach((entity) => { + expect(entity.protocols!).toBeGreaterThan(0); + expect(entity.protocols!).toBeLessThan(1000); + }); + } + }); + }); +}); + diff --git a/defi/api-tests/src/main-page/forks.test.ts b/defi/api-tests/src/main-page/forks.test.ts new file mode 100644 index 0000000000..00e59d0b07 --- /dev/null +++ b/defi/api-tests/src/main-page/forks.test.ts @@ -0,0 +1,126 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { ForksResponse, isForksResponse } from './types'; +import { forksResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.MAIN_PAGE.BASE_URL); + +describe('Main Page API - Forks', () => { + let forksResponse: ApiResponse; + + beforeAll(async () => { + forksResponse = await apiClient.get( + endpoints.MAIN_PAGE.FORKS + ); + }, 90000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(forksResponse); + expect(isForksResponse(forksResponse.data)).toBe(true); + expect(forksResponse.data).toHaveProperty('forks'); + expect(forksResponse.data).toHaveProperty('chart'); + expect(typeof forksResponse.data.forks).toBe('object'); + }); + + it('should validate against Zod schema', () => { + const result = validate( + forksResponse.data, + forksResponseSchema, + 'Forks' + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should have non-empty forks object', () => { + const forkKeys = Object.keys(forksResponse.data.forks); + expect(forkKeys.length).toBeGreaterThan(0); + }); + + it('should have minimum expected forks', () => { + const forkKeys = Object.keys(forksResponse.data.forks); + expect(forkKeys.length).toBeGreaterThan(5); + }); + }); + + describe('Fork Data Validation', () => { + it('should have valid fork names as keys', () => { + const forkKeys = Object.keys(forksResponse.data.forks); + + forkKeys.slice(0, 20).forEach((forkName) => { + expect(typeof forkName).toBe('string'); + expect(forkName.length).toBeGreaterThan(0); + }); + }); + + it('should have well-known forks', () => { + const forkKeys = Object.keys(forksResponse.data.forks).map(k => k.toLowerCase()); + const wellKnownForks = ['uniswap', 'compound', 'aave']; + + const foundForks = wellKnownForks.filter((name) => + forkKeys.some((fName) => fName.toLowerCase().includes(name)) + ); + + expect(foundForks.length).toBeGreaterThan(0); + }); + + it('should have various fork data types', () => { + const forks = forksResponse.data.forks; + const forkKeys = Object.keys(forks); + + if (forkKeys.length > 0) { + forkKeys.slice(0, 10).forEach((forkName) => { + const forkData = forks[forkName]; + expect(forkData).toBeDefined(); + }); + } + }); + }); + + describe('Chart Data Validation', () => { + it('should have chart data with timestamps', () => { + const chart = forksResponse.data.chart; + const timestamps = Object.keys(chart); + + expect(timestamps.length).toBeGreaterThan(0); + + timestamps.slice(0, 10).forEach((timestamp) => { + const ts = Number(timestamp); + expect(isNaN(ts)).toBe(false); + expect(ts).toBeGreaterThan(0); + }); + }); + + it('should have fork data in chart entries', () => { + const chart = forksResponse.data.chart; + const timestamps = Object.keys(chart); + + if (timestamps.length > 0) { + const firstTimestamp = timestamps[0]; + const forkData = chart[firstTimestamp]; + + expect(typeof forkData).toBe('object'); + + const forkKeys = Object.keys(forkData); + expect(forkKeys.length).toBeGreaterThan(0); + } + }); + + it('should have chronologically ordered timestamps', () => { + const timestamps = Object.keys(forksResponse.data.chart).map(Number); + + if (timestamps.length > 1) { + const sortedTimestamps = [...timestamps].sort((a, b) => a - b); + expect(timestamps).toEqual(sortedTimestamps); + } + }); + }); +}); diff --git a/defi/api-tests/src/main-page/hacks.test.ts b/defi/api-tests/src/main-page/hacks.test.ts new file mode 100644 index 0000000000..efb1e2be07 --- /dev/null +++ b/defi/api-tests/src/main-page/hacks.test.ts @@ -0,0 +1,154 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { HacksResponse, isHacksResponse } from './types'; +import { hacksResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectArrayResponse, + expectValidNumber, + expectNonNegativeNumber, + expectValidTimestamp, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.MAIN_PAGE.BASE_URL); + +describe('Main Page API - Hacks', () => { + let hacksResponse: ApiResponse; + + beforeAll(async () => { + hacksResponse = await apiClient.get( + endpoints.MAIN_PAGE.HACKS + ); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(hacksResponse); + expectArrayResponse(hacksResponse); + expect(isHacksResponse(hacksResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate( + hacksResponse.data, + hacksResponseSchema, + 'Hacks' + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should return a non-empty array', () => { + expect(hacksResponse.data.length).toBeGreaterThan(0); + }); + + it('should have minimum expected hacks', () => { + expect(hacksResponse.data.length).toBeGreaterThan(10); + }); + }); + + describe('Hack Item Validation', () => { + it('should have required fields in all hacks', () => { + hacksResponse.data.slice(0, 20).forEach((hack) => { + expect(hack).toHaveProperty('name'); + expect(hack).toHaveProperty('date'); + expect(hack).toHaveProperty('amount'); + + expect(typeof hack.name).toBe('string'); + expect(hack.name.length).toBeGreaterThan(0); + }); + }); + + it('should have valid date field', () => { + hacksResponse.data.slice(0, 20).forEach((hack) => { + const dateNum = typeof hack.date === 'string' ? Number(hack.date) : hack.date; + expect(typeof dateNum).toBe('number'); + expect(isNaN(dateNum)).toBe(false); + + // Check if it's a valid timestamp (in seconds) + if (dateNum > 10000000) { + expectValidTimestamp(dateNum); + } + }); + }); + + it('should have valid amount field', () => { + hacksResponse.data.slice(0, 20).forEach((hack) => { + if (hack.amount !== null) { + expectValidNumber(hack.amount); + expectNonNegativeNumber(hack.amount); + } + }); + }); + + it('should have valid optional fields when present', () => { + const hacksWithExtras = hacksResponse.data + .filter((h) => h.chain || h.classification) + .slice(0, 20); + + if (hacksWithExtras.length > 0) { + hacksWithExtras.forEach((hack) => { + if (hack.chain) { + // Chain can be a string or an array of strings + if (typeof hack.chain === 'string') { + expect(hack.chain.length).toBeGreaterThan(0); + } else if (Array.isArray(hack.chain)) { + expect(hack.chain.length).toBeGreaterThan(0); + } + } + if (hack.classification) { + expect(typeof hack.classification).toBe('string'); + } + if (hack.technique) { + expect(typeof hack.technique).toBe('string'); + } + }); + } + }); + }); + + describe('Data Quality Validation', () => { + it('should have valid chronological dates', () => { + if (hacksResponse.data.length > 1) { + const dates = hacksResponse.data.map((h) => { + return typeof h.date === 'string' ? Number(h.date) : h.date; + }).slice(0, 50); + + // Just verify all dates are valid timestamps + dates.forEach(date => { + expect(date).toBeGreaterThan(0); + expect(isNaN(date)).toBe(false); + }); + } + }); + + it('should have reasonable hack amounts', () => { + const hacksWithAmount = hacksResponse.data + .filter(h => h.amount !== null) + .slice(0, 20); + + expect(hacksWithAmount.length).toBeGreaterThan(0); + + hacksWithAmount.forEach((hack) => { + expect(hack.amount!).toBeGreaterThan(0); + expect(hack.amount!).toBeLessThan(1_000_000_000_000); // 1 trillion + }); + }); + + it('should have some high-profile hacks', () => { + const largeHacks = hacksResponse.data.filter((h) => h.amount !== null && h.amount! > 100_000_000); + expect(largeHacks.length).toBeGreaterThan(0); + }); + + it('should have total hack amount calculated correctly', () => { + const totalAmount = hacksResponse.data.reduce((sum, hack) => sum + (hack.amount ?? 0), 0); + expect(totalAmount).toBeGreaterThan(0); + expect(isNaN(totalAmount)).toBe(false); + }); + }); +}); + diff --git a/defi/api-tests/src/main-page/oracles.test.ts b/defi/api-tests/src/main-page/oracles.test.ts new file mode 100644 index 0000000000..9373cb614f --- /dev/null +++ b/defi/api-tests/src/main-page/oracles.test.ts @@ -0,0 +1,128 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { OraclesResponse, isOraclesResponse } from './types'; +import { oraclesResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.MAIN_PAGE.BASE_URL); + +describe('Main Page API - Oracles', () => { + let oraclesResponse: ApiResponse; + + beforeAll(async () => { + oraclesResponse = await apiClient.get( + endpoints.MAIN_PAGE.ORACLES + ); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(oraclesResponse); + expect(isOraclesResponse(oraclesResponse.data)).toBe(true); + expect(oraclesResponse.data).toHaveProperty('oracles'); + expect(typeof oraclesResponse.data.oracles).toBe('object'); + }); + + it('should validate against Zod schema', () => { + const result = validate( + oraclesResponse.data, + oraclesResponseSchema, + 'Oracles' + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should have non-empty oracles object', () => { + const oracleKeys = Object.keys(oraclesResponse.data.oracles); + expect(oracleKeys.length).toBeGreaterThan(0); + }); + + it('should have minimum expected oracles', () => { + const oracleKeys = Object.keys(oraclesResponse.data.oracles); + expect(oracleKeys.length).toBeGreaterThan(5); + }); + }); + + describe('Oracle Data Validation', () => { + it('should have valid oracle names as keys', () => { + const oracleKeys = Object.keys(oraclesResponse.data.oracles); + + oracleKeys.slice(0, 20).forEach((oracleName) => { + expect(typeof oracleName).toBe('string'); + expect(oracleName.length).toBeGreaterThan(0); + }); + }); + + it('should have well-known oracles', () => { + const oracleKeys = Object.keys(oraclesResponse.data.oracles).map(k => k.toLowerCase()); + const wellKnownOracles = ['chainlink', 'band', 'api3']; + + const foundOracles = wellKnownOracles.filter((name) => + oracleKeys.some((oName) => oName.toLowerCase().includes(name)) + ); + + expect(foundOracles.length).toBeGreaterThan(0); + }); + + it('should have various oracle data types', () => { + const oracles = oraclesResponse.data.oracles; + const oracleKeys = Object.keys(oracles); + + if (oracleKeys.length > 0) { + oracleKeys.slice(0, 10).forEach((oracleName) => { + const oracleData = oracles[oracleName]; + expect(oracleData).toBeDefined(); + }); + } + }); + }); + + describe('Additional Fields Validation', () => { + it('should have chart data when present', () => { + if (oraclesResponse.data.chart) { + const chart = oraclesResponse.data.chart; + expect(typeof chart).toBe('object'); + + const timestamps = Object.keys(chart); + if (timestamps.length > 0) { + timestamps.slice(0, 5).forEach((timestamp) => { + const ts = Number(timestamp); + expect(isNaN(ts)).toBe(false); + }); + } + } + }); + + it('should have oraclesTVS when present', () => { + if (oraclesResponse.data.oraclesTVS !== undefined) { + expect(oraclesResponse.data.oraclesTVS).toBeDefined(); + } + }); + + it('should have chainsByOracle when present', () => { + if (oraclesResponse.data.chainsByOracle) { + const chainsByOracle = oraclesResponse.data.chainsByOracle; + expect(typeof chainsByOracle).toBe('object'); + + const oracleKeys = Object.keys(chainsByOracle); + if (oracleKeys.length > 0) { + expect(oracleKeys.length).toBeGreaterThan(0); + } + } + }); + + it('should have chainChart when present', () => { + if (oraclesResponse.data.chainChart) { + const chainChart = oraclesResponse.data.chainChart; + expect(typeof chainChart).toBe('object'); + } + }); + }); +}); diff --git a/defi/api-tests/src/main-page/raises.test.ts b/defi/api-tests/src/main-page/raises.test.ts new file mode 100644 index 0000000000..b77c724c98 --- /dev/null +++ b/defi/api-tests/src/main-page/raises.test.ts @@ -0,0 +1,194 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { RaisesResponse, isRaisesResponse } from './types'; +import { raisesResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, + expectValidTimestamp, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.MAIN_PAGE.BASE_URL); + +describe('Main Page API - Raises', () => { + let raisesResponse: ApiResponse; + + beforeAll(async () => { + raisesResponse = await apiClient.get( + endpoints.MAIN_PAGE.RAISES + ); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(raisesResponse); + expect(isRaisesResponse(raisesResponse.data)).toBe(true); + expect(raisesResponse.data).toHaveProperty('raises'); + expect(Array.isArray(raisesResponse.data.raises)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate( + raisesResponse.data, + raisesResponseSchema, + 'Raises' + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should return a non-empty raises array', () => { + expect(raisesResponse.data.raises.length).toBeGreaterThan(0); + }); + + it('should have minimum expected raises', () => { + expect(raisesResponse.data.raises.length).toBeGreaterThan(10); + }); + }); + + describe('Raise Item Validation', () => { + it('should have required fields in all raises', () => { + raisesResponse.data.raises.slice(0, 20).forEach((raise) => { + expect(raise).toHaveProperty('name'); + expect(raise).toHaveProperty('date'); + expect(raise).toHaveProperty('amount'); + + expect(typeof raise.name).toBe('string'); + expect(raise.name.length).toBeGreaterThan(0); + }); + }); + + it('should have valid date field', () => { + raisesResponse.data.raises.slice(0, 20).forEach((raise) => { + const dateNum = typeof raise.date === 'string' ? Number(raise.date) : raise.date; + expect(typeof dateNum).toBe('number'); + expect(isNaN(dateNum)).toBe(false); + + // Check if it's a valid timestamp + if (dateNum > 10000000) { + expectValidTimestamp(dateNum); + } + }); + }); + + it('should have valid amount field', () => { + raisesResponse.data.raises.slice(0, 20).forEach((raise) => { + if (raise.amount !== null) { + expectValidNumber(raise.amount); + expectNonNegativeNumber(raise.amount); + } + }); + }); + + it('should have valid valuation when present', () => { + const raisesWithValuation = raisesResponse.data.raises + .filter((r) => r.valuation !== undefined && r.valuation !== null) + .slice(0, 20); + + if (raisesWithValuation.length > 0) { + raisesWithValuation.forEach((raise) => { + const val = typeof raise.valuation === 'string' ? Number(raise.valuation) : raise.valuation!; + expectValidNumber(val); + expectNonNegativeNumber(val); + }); + } + }); + + it('should have valid optional fields when present', () => { + const raisesWithExtras = raisesResponse.data.raises + .filter((r) => r.round || r.sector) + .slice(0, 20); + + if (raisesWithExtras.length > 0) { + raisesWithExtras.forEach((raise) => { + if (raise.round) { + expect(typeof raise.round).toBe('string'); + } + if (raise.sector) { + expect(typeof raise.sector).toBe('string'); + } + if (raise.category) { + expect(typeof raise.category).toBe('string'); + } + }); + } + }); + + it('should have valid investor arrays when present', () => { + const raisesWithInvestors = raisesResponse.data.raises + .filter((r) => r.leadInvestors || r.otherInvestors) + .slice(0, 20); + + if (raisesWithInvestors.length > 0) { + raisesWithInvestors.forEach((raise) => { + if (raise.leadInvestors) { + expect(Array.isArray(raise.leadInvestors)).toBe(true); + raise.leadInvestors.forEach((investor) => { + expect(typeof investor).toBe('string'); + }); + } + if (raise.otherInvestors) { + expect(Array.isArray(raise.otherInvestors)).toBe(true); + raise.otherInvestors.forEach((investor) => { + expect(typeof investor).toBe('string'); + }); + } + }); + } + }); + }); + + describe('Data Quality Validation', () => { + it('should have valid chronological dates', () => { + if (raisesResponse.data.raises.length > 1) { + const dates = raisesResponse.data.raises.map((r) => { + return typeof r.date === 'string' ? Number(r.date) : r.date; + }).slice(0, 50); + + // Just verify all dates are valid timestamps + dates.forEach(date => { + expect(date).toBeGreaterThan(0); + expect(isNaN(date)).toBe(false); + }); + } + }); + + it('should have reasonable raise amounts when present', () => { + const raisesWithAmount = raisesResponse.data.raises + .filter(r => r.amount !== null) + .slice(0, 20); + + expect(raisesWithAmount.length).toBeGreaterThan(0); + + raisesWithAmount.forEach((raise) => { + expect(raise.amount!).toBeGreaterThan(0); + expect(raise.amount!).toBeLessThan(10_000_000_000); // 10 billion + }); + }); + + it('should have raises with various amounts', () => { + const raisesWithAmount = raisesResponse.data.raises + .filter(r => r.amount !== null) + .slice(0, 20); + + // Just verify we have various amounts + expect(raisesWithAmount.length).toBeGreaterThan(0); + raisesWithAmount.forEach(raise => { + expect(raise.amount!).toBeGreaterThan(0); + }); + }); + + it('should have total raise amount calculated correctly', () => { + const totalAmount = raisesResponse.data.raises + .filter(r => r.amount !== null) + .reduce((sum, raise) => sum + raise.amount!, 0); + expect(totalAmount).toBeGreaterThan(0); + expect(isNaN(totalAmount)).toBe(false); + }); + }); +}); diff --git a/defi/api-tests/src/main-page/schemas.ts b/defi/api-tests/src/main-page/schemas.ts new file mode 100644 index 0000000000..d024ecd9d5 --- /dev/null +++ b/defi/api-tests/src/main-page/schemas.ts @@ -0,0 +1,113 @@ +import { z } from 'zod'; + +// Schema for categories response +// The response is an object with category names as keys and arrays of protocol names as values +export const categoriesResponseSchema = z.object({ + categories: z.record(z.string(), z.array(z.string())), + chart: z.record(z.string(), z.record(z.string(), z.object({ tvl: z.number().optional() }).passthrough())), +}); + +// Individual category schema (not used for the main response, but kept for reference) +export const categorySchema = z.object({ + name: z.string(), + protocols: z.array(z.string()), +}); + +// Schema for fork item +export const forkSchema = z.object({ + name: z.string(), + id: z.string().optional(), + protocols: z.number().optional(), + tvl: z.number().optional(), + forkedFrom: z.array(z.string()).optional(), + change_1h: z.number().nullable().optional(), + change_1d: z.number().nullable().optional(), + change_7d: z.number().nullable().optional(), +}); + +export const forksResponseSchema = z.object({ + forks: z.record(z.string(), z.any()), // Object with fork names as keys + chart: z.record(z.string(), z.record(z.string(), z.object({ tvl: z.number().optional() }).passthrough())), +}); + +// Schema for oracle item +export const oracleSchema = z.object({ + name: z.string(), + id: z.string().optional(), + protocols: z.number().optional(), + tvl: z.number().optional(), + change_1h: z.number().nullable().optional(), + change_1d: z.number().nullable().optional(), + change_7d: z.number().nullable().optional(), +}); + +export const oraclesResponseSchema = z.object({ + oracles: z.record(z.string(), z.any()), // Object with oracle names as keys + chart: z.record(z.string(), z.any()).optional(), + chainChart: z.record(z.string(), z.any()).optional(), + oraclesTVS: z.any().optional(), // Can be number or object + chainsByOracle: z.record(z.string(), z.any()).optional(), +}); + +// Schema for hack item +export const hackSchema = z.object({ + name: z.string(), + date: z.union([z.number(), z.string()]), + amount: z.number().nullable(), + chain: z.union([z.string(), z.array(z.string())]).nullable().optional(), + classification: z.string().nullable().optional(), + technique: z.string().nullable().optional(), + bridge: z.string().nullable().optional(), + defillamaId: z.union([z.string(), z.number()]).nullable().optional(), +}); + +export const hacksResponseSchema = z.array(hackSchema); + +// Schema for raise item +export const raiseSchema = z.object({ + name: z.string(), + date: z.union([z.number(), z.string()]), + amount: z.number().nullable(), + round: z.string().nullable().optional(), + sector: z.string().nullable().optional(), + category: z.string().nullable().optional(), + source: z.string().nullable().optional(), + leadInvestors: z.array(z.string()).optional(), + otherInvestors: z.array(z.string()).optional(), + valuation: z.union([z.number(), z.string()]).nullable().optional(), + defillamaId: z.string().optional(), + chains: z.array(z.string()).optional(), +}); + +export const raisesResponseSchema = z.object({ + raises: z.array(raiseSchema), +}); + +// Schema for treasury item +export const treasurySchema = z.object({ + name: z.string(), + id: z.string().optional(), + symbol: z.string().optional(), + tvl: z.number().optional(), + tokenBreakdowns: z.record(z.string(), z.number()).optional(), + chainBreakdowns: z.record(z.string(), z.number()).optional(), + change_1h: z.number().nullable().optional(), + change_1d: z.number().nullable().optional(), + change_7d: z.number().nullable().optional(), +}); + +export const treasuriesResponseSchema = z.array(treasurySchema); + +// Schema for entity item +export const entitySchema = z.object({ + name: z.string(), + id: z.string().optional(), + protocols: z.number().optional(), + tvl: z.number().optional(), + change_1h: z.number().nullable().optional(), + change_1d: z.number().nullable().optional(), + change_7d: z.number().nullable().optional(), +}); + +export const entitiesResponseSchema = z.array(entitySchema); + diff --git a/defi/api-tests/src/main-page/treasuries.test.ts b/defi/api-tests/src/main-page/treasuries.test.ts new file mode 100644 index 0000000000..c2c2a3e083 --- /dev/null +++ b/defi/api-tests/src/main-page/treasuries.test.ts @@ -0,0 +1,185 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { TreasuriesResponse, isTreasuriesResponse } from './types'; +import { treasuriesResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectArrayResponse, + expectValidNumber, + expectNonNegativeNumber, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.MAIN_PAGE.BASE_URL); + +describe('Main Page API - Treasuries', () => { + let treasuriesResponse: ApiResponse; + + beforeAll(async () => { + treasuriesResponse = await apiClient.get( + endpoints.MAIN_PAGE.TREASURIES + ); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(treasuriesResponse); + expectArrayResponse(treasuriesResponse); + expect(isTreasuriesResponse(treasuriesResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate( + treasuriesResponse.data, + treasuriesResponseSchema, + 'Treasuries' + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should return a non-empty array', () => { + expect(treasuriesResponse.data.length).toBeGreaterThan(0); + }); + }); + + describe('Treasury Item Validation', () => { + it('should have required fields in all treasuries', () => { + treasuriesResponse.data.slice(0, 20).forEach((treasury) => { + expect(treasury).toHaveProperty('name'); + expect(typeof treasury.name).toBe('string'); + expect(treasury.name.length).toBeGreaterThan(0); + }); + }); + + it('should have valid numeric fields when present', () => { + const treasuriesWithData = treasuriesResponse.data + .filter((t) => t.tvl !== undefined) + .slice(0, 20); + + expect(treasuriesWithData.length).toBeGreaterThan(0); + + treasuriesWithData.forEach((treasury) => { + if (treasury.tvl !== undefined) { + expectValidNumber(treasury.tvl); + expectNonNegativeNumber(treasury.tvl); + } + }); + }); + + it('should have valid percentage change fields when present', () => { + const treasuriesWithChange = treasuriesResponse.data + .filter((t) => t.change_1d !== null && t.change_1d !== undefined) + .slice(0, 20); + + if (treasuriesWithChange.length > 0) { + treasuriesWithChange.forEach((treasury) => { + if (treasury.change_1h !== null && treasury.change_1h !== undefined) { + expectValidNumber(treasury.change_1h); + } + if (treasury.change_1d !== null && treasury.change_1d !== undefined) { + expectValidNumber(treasury.change_1d); + } + if (treasury.change_7d !== null && treasury.change_7d !== undefined) { + expectValidNumber(treasury.change_7d); + } + }); + } + }); + + it('should have valid token breakdowns when present', () => { + const treasuriesWithTokens = treasuriesResponse.data + .filter((t) => t.tokenBreakdowns !== undefined) + .slice(0, 10); + + if (treasuriesWithTokens.length > 0) { + treasuriesWithTokens.forEach((treasury) => { + expect(typeof treasury.tokenBreakdowns).toBe('object'); + + Object.entries(treasury.tokenBreakdowns!).forEach(([token, amount]) => { + expect(typeof token).toBe('string'); + expectValidNumber(amount); + // Token amounts can be negative (debt) + }); + }); + } + }); + + it('should have valid chain breakdowns when present', () => { + const treasuriesWithChains = treasuriesResponse.data + .filter((t) => t.chainBreakdowns !== undefined) + .slice(0, 10); + + if (treasuriesWithChains.length > 0) { + treasuriesWithChains.forEach((treasury) => { + expect(typeof treasury.chainBreakdowns).toBe('object'); + + Object.entries(treasury.chainBreakdowns!).forEach(([chain, amount]) => { + expect(typeof chain).toBe('string'); + expectValidNumber(amount); + expectNonNegativeNumber(amount); + }); + }); + } + }); + + it('should have unique treasury names', () => { + const names = treasuriesResponse.data.map((t) => t.name); + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(names.length); + }); + }); + + describe('Data Quality Validation', () => { + it('should have treasuries sorted by TVL in descending order', () => { + const treasuriesWithTvl = treasuriesResponse.data + .filter((t) => t.tvl !== undefined) + .slice(0, 50); + + if (treasuriesWithTvl.length > 1) { + for (let i = 0; i < treasuriesWithTvl.length - 1; i++) { + expect(treasuriesWithTvl[i].tvl!).toBeGreaterThanOrEqual( + treasuriesWithTvl[i + 1].tvl! + ); + } + } + }); + + it('should have reasonable TVL values', () => { + const topTreasuries = treasuriesResponse.data + .filter((t) => t.tvl !== undefined) + .slice(0, 10); + + topTreasuries.forEach((treasury) => { + if (treasury.tvl !== undefined) { + expect(treasury.tvl).toBeGreaterThan(0); + expect(treasury.tvl).toBeLessThan(100_000_000_000); // 100 billion + } + }); + }); + + it('should have token breakdown amounts sum close to TVL', () => { + const treasuriesWithBreakdown = treasuriesResponse.data + .filter((t) => t.tvl && t.tokenBreakdowns) + .slice(0, 5); + + if (treasuriesWithBreakdown.length > 0) { + treasuriesWithBreakdown.forEach((treasury) => { + const tokenSum = Object.values(treasury.tokenBreakdowns!).reduce( + (sum, amount) => sum + amount, + 0 + ); + + // Token breakdown can significantly differ from TVL (due to debt, price changes, etc) + // Just verify both values are defined and reasonable + expect(tokenSum).toBeDefined(); + expect(isNaN(tokenSum)).toBe(false); + }); + } + }); + }); +}); + diff --git a/defi/api-tests/src/main-page/types.ts b/defi/api-tests/src/main-page/types.ts new file mode 100644 index 0000000000..3e8c6406e5 --- /dev/null +++ b/defi/api-tests/src/main-page/types.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; +import { + categorySchema, + categoriesResponseSchema, + forkSchema, + forksResponseSchema, + oracleSchema, + oraclesResponseSchema, + hackSchema, + hacksResponseSchema, + raiseSchema, + raisesResponseSchema, + treasurySchema, + treasuriesResponseSchema, + entitySchema, + entitiesResponseSchema, +} from './schemas'; + +// Infer types from schemas +export type Category = z.infer; +export type CategoriesResponse = z.infer; +export type CategoryArray = Category[]; + +export type Fork = z.infer; +export type ForksResponse = z.infer; + +export type Oracle = z.infer; +export type OraclesResponse = z.infer; + +export type Hack = z.infer; +export type HacksResponse = z.infer; + +export type Raise = z.infer; +export type RaisesResponse = z.infer; + +export type Treasury = z.infer; +export type TreasuriesResponse = z.infer; + +export type Entity = z.infer; +export type EntitiesResponse = z.infer; + +// Type guards +export function isCategoriesResponse(data: unknown): data is CategoriesResponse { + return categoriesResponseSchema.safeParse(data).success; +} + +export function isForksResponse(data: unknown): data is ForksResponse { + return forksResponseSchema.safeParse(data).success; +} + +export function isOraclesResponse(data: unknown): data is OraclesResponse { + return oraclesResponseSchema.safeParse(data).success; +} + +export function isHacksResponse(data: unknown): data is HacksResponse { + return hacksResponseSchema.safeParse(data).success; +} + +export function isRaisesResponse(data: unknown): data is RaisesResponse { + return raisesResponseSchema.safeParse(data).success; +} + +export function isTreasuriesResponse(data: unknown): data is TreasuriesResponse { + return treasuriesResponseSchema.safeParse(data).success; +} + +export function isEntitiesResponse(data: unknown): data is EntitiesResponse { + return entitiesResponseSchema.safeParse(data).success; +} + diff --git a/defi/api-tests/src/narratives/fdvPerformance.test.ts b/defi/api-tests/src/narratives/fdvPerformance.test.ts new file mode 100644 index 0000000000..1fe47567cb --- /dev/null +++ b/defi/api-tests/src/narratives/fdvPerformance.test.ts @@ -0,0 +1,270 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { NarrativePerformanceResponse, isNarrativePerformanceResponse } from './types'; +import { narrativePerformanceResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, +} from '../../utils/testHelpers'; +import { ApiResponse } from '../../utils/config/apiClient'; +import { validate } from '../../utils/validation'; + +const apiClient = createApiClient(endpoints.NARRATIVES.BASE_URL); + +describe('Narratives API - FDV Performance', () => { + const testPeriods = ['7', '30']; + const responses: Record> = {}; + + beforeAll(async () => { + const results = await Promise.all( + testPeriods.map((period) => + apiClient.get(endpoints.NARRATIVES.FDV_PERFORMANCE(period)) + ) + ); + + testPeriods.forEach((period, index) => { + responses[period] = results[index]; + }); + }, 90000); + + testPeriods.forEach((period) => { + describe(`Period: ${period}`, () => { + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + const response = responses[period]; + expectSuccessfulResponse(response); + expect(isNarrativePerformanceResponse(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const response = responses[period]; + if (response.status === 200) { + const result = validate( + response.data, + narrativePerformanceResponseSchema, + 'NarrativePerformanceResponse' + ); + expect(result.success).toBe(true); + } + }); + + it('should return an array', () => { + const response = responses[period]; + if (response.status === 200) { + expect(Array.isArray(response.data)).toBe(true); + expect(response.data.length).toBeGreaterThan(0); + } + }); + }); + + describe('Data Quality Validation', () => { + it('should have reasonable number of data points', () => { + const response = responses[period]; + if (response.status === 200) { + expect(response.data.length).toBeGreaterThan(1); + expect(response.data.length).toBeLessThan(1000); + } + }); + + it('should have chronologically ordered timestamps', () => { + const response = responses[period]; + if (response.status === 200) { + const timestamps = response.data.map((point) => point.date); + + for (let i = 1; i < timestamps.length; i++) { + expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]); + } + } + }); + + it('should have consistent narrative categories across data points', () => { + const response = responses[period]; + if (response.status === 200 && response.data.length > 1) { + // Get categories from first data point (excluding 'date') + const firstPointCategories = Object.keys(response.data[0]).filter((key) => key !== 'date').sort(); + + // Check that at least a few subsequent points have the same categories + const samplesToCheck = Math.min(5, response.data.length); + for (let i = 1; i < samplesToCheck; i++) { + const pointCategories = Object.keys(response.data[i]).filter((key) => key !== 'date').sort(); + expect(pointCategories).toEqual(firstPointCategories); + } + } + }); + + it('should have well-known narrative categories', () => { + const response = responses[period]; + if (response.status === 200 && response.data.length > 0) { + const categories = Object.keys(response.data[0]).filter((key) => key !== 'date'); + + // Check for some well-known categories + const wellKnownCategories = [ + 'Artificial Intelligence (AI)', + 'Decentralized Finance (DeFi)', + 'Meme', + 'Gaming (GameFi)', + ]; + + const foundCategories = wellKnownCategories.filter((cat) => categories.includes(cat)); + expect(foundCategories.length).toBeGreaterThan(0); + } + }); + }); + + describe('Data Point Validation', () => { + it('should have valid date field in all points', () => { + const response = responses[period]; + if (response.status === 200) { + response.data.slice(0, 20).forEach((point) => { + expect(point).toHaveProperty('date'); + expectValidNumber(point.date); + expect(point.date).toBeGreaterThan(0); + + // Date should be a valid Unix timestamp (reasonable range) + const now = Math.floor(Date.now() / 1000); + expect(point.date).toBeLessThanOrEqual(now + 86400 * 365); // Not more than 1 year in future + expect(point.date).toBeGreaterThan(now - 86400 * 365 * 5); // Not more than 5 years in past + }); + } + }); + + it('should have valid percentage values for categories', () => { + const response = responses[period]; + if (response.status === 200) { + response.data.slice(0, 20).forEach((point) => { + const categories = Object.keys(point).filter((key) => key !== 'date'); + + categories.forEach((category) => { + const value = point[category]; + expectValidNumber(value); + + // Percentage changes can be positive or negative + // But should be reasonable (not more than ±1000%) + expect(value).toBeGreaterThan(-1000); + expect(value).toBeLessThan(1000); + }); + }); + } + }); + + it('should have at least one category with data in first point', () => { + const response = responses[period]; + if (response.status === 200 && response.data.length > 0) { + const firstPoint = response.data[0]; + const categories = Object.keys(firstPoint).filter((key) => key !== 'date'); + + expect(categories.length).toBeGreaterThan(0); + + // First point might have all zeros (baseline), so just check structure + categories.forEach((category) => { + expect(typeof firstPoint[category]).toBe('number'); + }); + } + }); + + it('should have non-zero values in later data points', () => { + const response = responses[period]; + if (response.status === 200 && response.data.length > 1) { + // Check that at least some data points have non-zero values + const laterPoints = response.data.slice(1, Math.min(10, response.data.length)); + + let hasNonZeroValues = false; + laterPoints.forEach((point) => { + const categories = Object.keys(point).filter((key) => key !== 'date'); + const nonZeroCategories = categories.filter((cat) => point[cat] !== 0); + + if (nonZeroCategories.length > 0) { + hasNonZeroValues = true; + } + }); + + expect(hasNonZeroValues).toBe(true); + } + }); + }); + + describe('Period-Specific Validation', () => { + it('should have appropriate time span for period', () => { + const response = responses[period]; + if (response.status === 200 && response.data.length > 1) { + const firstTimestamp = response.data[0].date; + const lastTimestamp = response.data[response.data.length - 1].date; + const timeSpanDays = (lastTimestamp - firstTimestamp) / 86400; + + // Expected time spans (with some tolerance) + const expectedSpans: Record = { + '7': { min: 5, max: 10 }, + '30': { min: 25, max: 35 }, + }; + + const expected = expectedSpans[period]; + if (expected) { + expect(timeSpanDays).toBeGreaterThan(expected.min); + expect(timeSpanDays).toBeLessThan(expected.max); + } + } + }); + }); + }); + }); + + describe('Cross-Period Comparison', () => { + it('should have different time spans for different periods', () => { + const successfulResponses = testPeriods + .filter((period) => responses[period].status === 200 && responses[period].data.length > 1) + .map((period) => ({ + period, + data: responses[period].data, + })); + + if (successfulResponses.length > 1) { + const timeSpans = successfulResponses.map((resp) => { + const first = resp.data[0].date; + const last = resp.data[resp.data.length - 1].date; + return last - first; + }); + + // Check that time spans are different + const uniqueSpans = new Set(timeSpans); + if (successfulResponses.length > 1) { + // Allow for some periods to have same span + expect(uniqueSpans.size).toBeGreaterThanOrEqual(1); + } + } + }); + + it('should have consistent category lists across periods', () => { + const successfulResponses = testPeriods + .filter((period) => responses[period].status === 200 && responses[period].data.length > 0) + .map((period) => responses[period].data); + + if (successfulResponses.length > 1) { + const categoryLists = successfulResponses.map((data) => + Object.keys(data[0]).filter((key) => key !== 'date').sort() + ); + + // All periods should have the same categories + const firstList = categoryLists[0]; + categoryLists.slice(1).forEach((list) => { + expect(list).toEqual(firstList); + }); + } + }); + }); + + describe('Endpoint Configuration', () => { + it('should have correct base URL', () => { + expect(endpoints.NARRATIVES.BASE_URL).toBeDefined(); + expect(typeof endpoints.NARRATIVES.BASE_URL).toBe('string'); + expect(endpoints.NARRATIVES.BASE_URL.length).toBeGreaterThan(0); + }); + + it('should generate correct endpoint paths', () => { + testPeriods.forEach((period) => { + const path = endpoints.NARRATIVES.FDV_PERFORMANCE(period); + expect(path).toBe(`/fdv/performance/${period}`); + }); + }); + }); +}); + diff --git a/defi/api-tests/src/narratives/schemas.ts b/defi/api-tests/src/narratives/schemas.ts new file mode 100644 index 0000000000..4288488f9b --- /dev/null +++ b/defi/api-tests/src/narratives/schemas.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +// Narrative performance data point schema +// Each object has a date field and dynamic category fields with percentage values +export const narrativePerformancePointSchema = z.object({ + date: z.number(), +}).catchall(z.number()); // Allow any additional string keys with number values + +// Narrative performance response is an array of data points +export const narrativePerformanceResponseSchema = z.array(narrativePerformancePointSchema); + diff --git a/defi/api-tests/src/narratives/types.ts b/defi/api-tests/src/narratives/types.ts new file mode 100644 index 0000000000..7e73d7d93b --- /dev/null +++ b/defi/api-tests/src/narratives/types.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; +import { + narrativePerformancePointSchema, + narrativePerformanceResponseSchema, +} from './schemas'; + +// Inferred types +export type NarrativePerformancePoint = z.infer; +export type NarrativePerformanceResponse = z.infer; + +// Type guards +export function isNarrativePerformanceResponse(data: any): data is NarrativePerformanceResponse { + return Array.isArray(data) && data.length > 0 && data.every((item) => 'date' in item); +} + +export function isNarrativePerformancePoint(data: any): data is NarrativePerformancePoint { + return data && typeof data === 'object' && 'date' in data && typeof data.date === 'number'; +} + diff --git a/defi/api-tests/src/perps/derivatives.test.ts b/defi/api-tests/src/perps/derivatives.test.ts new file mode 100644 index 0000000000..eaaa5b4b55 --- /dev/null +++ b/defi/api-tests/src/perps/derivatives.test.ts @@ -0,0 +1,305 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { PerpsOverviewResponse, PerpsSummaryResponse, isPerpsOverviewResponse, isPerpsSummaryResponse } from './types'; +import { perpsOverviewResponseSchema, perpsSummaryResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, +} from '../../utils/testHelpers'; +import { ApiResponse } from '../../utils/config/apiClient'; +import { validate } from '../../utils/validation'; + +const apiClient = createApiClient(endpoints.PERPS.BASE_URL); + +describe('Perps API - Derivatives', () => { + let overviewResponse: ApiResponse; + + beforeAll(async () => { + overviewResponse = await apiClient.get( + endpoints.PERPS.OVERVIEW_DERIVATIVES + ); + }, 60000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(overviewResponse); + expect(isPerpsOverviewResponse(overviewResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate( + overviewResponse.data, + perpsOverviewResponseSchema, + 'PerpsOverviewResponse' + ); + expect(result.success).toBe(true); + }); + + it('should return an object with protocols', () => { + expect(typeof overviewResponse.data).toBe('object'); + expect(overviewResponse.data).not.toBeNull(); + expect(overviewResponse.data).toHaveProperty('protocols'); + }); + + it('should have minimum expected protocols', () => { + if (overviewResponse.data.protocols) { + expect(Array.isArray(overviewResponse.data.protocols)).toBe(true); + expect(overviewResponse.data.protocols.length).toBeGreaterThan(5); + } + }); + + it('should have aggregated metrics', () => { + expect(overviewResponse.data).toHaveProperty('total24h'); + if (overviewResponse.data.total24h !== null && overviewResponse.data.total24h !== undefined) { + expectValidNumber(overviewResponse.data.total24h); + expectNonNegativeNumber(overviewResponse.data.total24h); + } + }); + }); + + describe('Data Quality Validation', () => { + it('should have protocols sorted by volume', () => { + if (!overviewResponse.data.protocols) return; + + const protocolsWithVolume = overviewResponse.data.protocols + .filter((p) => p.total24h !== null && p.total24h !== undefined && p.total24h > 1000) + .slice(0, 20); + + if (protocolsWithVolume.length > 1) { + let sortedPairs = 0; + let totalPairs = 0; + + for (let i = 1; i < protocolsWithVolume.length; i++) { + const prev = Number(protocolsWithVolume[i - 1].total24h); + const curr = Number(protocolsWithVolume[i].total24h); + totalPairs++; + if (prev >= curr) sortedPairs++; + } + + const sortedPercentage = (sortedPairs / totalPairs) * 100; + expect(sortedPercentage).toBeGreaterThan(50); + } + }); + + it('should have protocols with data', () => { + if (!overviewResponse.data.protocols) return; + + const protocolsWithData = overviewResponse.data.protocols.filter( + (p) => p.total24h !== null && p.total24h !== undefined && p.total24h > 0 + ); + expect(protocolsWithData.length).toBeGreaterThanOrEqual(5); + }); + + it('should have multiple chains represented', () => { + if (!overviewResponse.data.protocols) return; + + const allChains = new Set(); + overviewResponse.data.protocols.forEach((protocol) => { + if (protocol.chains) { + protocol.chains.forEach((chain) => allChains.add(chain)); + } + }); + expect(allChains.size).toBeGreaterThan(3); + }); + + it('should have well-known perps protocols', () => { + if (!overviewResponse.data.protocols) return; + + const protocolNames = overviewResponse.data.protocols.map((p) => p.name.toLowerCase()); + + const wellKnownProtocols = ['gmx', 'hyperliquid', 'dydx']; + const foundProtocols = wellKnownProtocols.filter((name) => + protocolNames.some((pName) => pName.includes(name)) + ); + + expect(foundProtocols.length).toBeGreaterThan(0); + }); + }); + + describe('Protocol Item Validation', () => { + it('should have required fields in all protocols', () => { + if (!overviewResponse.data.protocols) return; + + overviewResponse.data.protocols.slice(0, 20).forEach((protocol) => { + expect(protocol).toHaveProperty('name'); + expect(typeof protocol.name).toBe('string'); + expect(protocol.name.length).toBeGreaterThan(0); + }); + }); + + it('should have valid volume metrics when present', () => { + if (!overviewResponse.data.protocols) return; + + const protocolsWithVolume = overviewResponse.data.protocols + .filter((p) => p.total24h !== null && p.total24h !== undefined) + .slice(0, 20); + + if (protocolsWithVolume.length > 0) { + protocolsWithVolume.forEach((protocol) => { + expectValidNumber(protocol.total24h!); + expectNonNegativeNumber(protocol.total24h!); + }); + } + }); + + it('should have valid change percentages when present', () => { + if (!overviewResponse.data.protocols) return; + + const protocolsWithChange = overviewResponse.data.protocols + .filter((p) => p.change_1d !== null && p.change_1d !== undefined) + .slice(0, 20); + + if (protocolsWithChange.length > 0) { + protocolsWithChange.forEach((protocol) => { + expectValidNumber(protocol.change_1d!); + expect(protocol.change_1d).toBeGreaterThanOrEqual(-100); + expect(protocol.change_1d).toBeLessThan(10000); // Some protocols can have very high growth + }); + } + }); + + it('should have valid chart data when present', () => { + if (!overviewResponse.data.protocols) return; + + const protocolsWithChart = overviewResponse.data.protocols + .filter((p) => p.totalDataChart && p.totalDataChart.length > 0) + .slice(0, 5); + + if (protocolsWithChart.length > 0) { + protocolsWithChart.forEach((protocol) => { + expect(Array.isArray(protocol.totalDataChart)).toBe(true); + expect(protocol.totalDataChart!.length).toBeGreaterThan(0); + + protocol.totalDataChart!.slice(0, 5).forEach(([timestamp, value]) => { + expectValidNumber(timestamp); + expectValidNumber(value); + expect(timestamp).toBeGreaterThan(0); + }); + }); + } + }); + }); +}); + +describe('Perps API - Summary', () => { + const testProtocols = ['gmx', 'hyperliquid', 'dydx']; + const responses: Record> = {}; + + beforeAll(async () => { + const results = await Promise.all( + testProtocols.map((protocol) => + apiClient.get(endpoints.PERPS.SUMMARY_DERIVATIVES(protocol)) + ) + ); + + testProtocols.forEach((protocol, index) => { + responses[protocol] = results[index]; + }); + }, 90000); + + testProtocols.forEach((protocol) => { + describe(`Protocol: ${protocol}`, () => { + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + const response = responses[protocol]; + expectSuccessfulResponse(response); + expect(isPerpsSummaryResponse(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const response = responses[protocol]; + const result = validate( + response.data, + perpsSummaryResponseSchema, + `PerpsSummary-${protocol}` + ); + expect(result.success).toBe(true); + }); + + it('should have protocol name', () => { + const response = responses[protocol]; + expect(response.data).toHaveProperty('name'); + expect(typeof response.data.name).toBe('string'); + expect(response.data.name.length).toBeGreaterThan(0); + }); + + it('should have at least one chain', () => { + const response = responses[protocol]; + if (response.data.chains) { + expect(Array.isArray(response.data.chains)).toBe(true); + expect(response.data.chains.length).toBeGreaterThan(0); + } + }); + }); + + describe('Metrics Validation', () => { + it('should have valid volume metrics when present', () => { + const response = responses[protocol]; + const data = response.data; + + if (data.total24h !== null && data.total24h !== undefined) { + expectValidNumber(data.total24h); + expectNonNegativeNumber(data.total24h); + } + + if (data.total7d !== null && data.total7d !== undefined) { + expectValidNumber(data.total7d); + expectNonNegativeNumber(data.total7d); + } + + if (data.total30d !== null && data.total30d !== undefined) { + expectValidNumber(data.total30d); + expectNonNegativeNumber(data.total30d); + } + }); + + it('should have valid change percentage when present', () => { + const response = responses[protocol]; + const data = response.data; + + if (data.change_1d !== null && data.change_1d !== undefined) { + expectValidNumber(data.change_1d); + expect(data.change_1d).toBeGreaterThan(-100); + expect(data.change_1d).toBeLessThan(1000); + } + }); + }); + + describe('Chart Data Validation', () => { + it('should have chart data when present', () => { + const response = responses[protocol]; + const data = response.data; + + if (data.totalDataChart) { + expect(Array.isArray(data.totalDataChart)).toBe(true); + expect(data.totalDataChart.length).toBeGreaterThan(0); + } + }); + + it('should have chronologically ordered chart data when present', () => { + const response = responses[protocol]; + const data = response.data; + + if (data.totalDataChart && data.totalDataChart.length > 1) { + for (let i = 1; i < data.totalDataChart.length; i++) { + expect(data.totalDataChart[i][0]).toBeGreaterThanOrEqual( + data.totalDataChart[i - 1][0] + ); + } + } + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent protocol gracefully', async () => { + const response = await apiClient.get(endpoints.PERPS.SUMMARY_DERIVATIVES('non-existent-protocol-xyz')); + + expect(response).toBeDefined(); + expect(response.status).toBeDefined(); + }, 30000); + }); +}); + diff --git a/defi/api-tests/src/perps/openInterest.test.ts b/defi/api-tests/src/perps/openInterest.test.ts new file mode 100644 index 0000000000..98672e947a --- /dev/null +++ b/defi/api-tests/src/perps/openInterest.test.ts @@ -0,0 +1,129 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { OpenInterestResponse, isOpenInterestResponse } from './types'; +import { openInterestResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, + expectFreshData, +} from '../../utils/testHelpers'; +import { ApiResponse } from '../../utils/config/apiClient'; +import { validate } from '../../utils/validation'; + +const apiClient = createApiClient(endpoints.PERPS.BASE_URL); + +describe('Perps API - Open Interest', () => { + let openInterestResponse: ApiResponse; + + beforeAll(async () => { + openInterestResponse = await apiClient.get( + endpoints.PERPS.OVERVIEW_OPEN_INTEREST + ); + }, 60000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(openInterestResponse); + expect(isOpenInterestResponse(openInterestResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate( + openInterestResponse.data, + openInterestResponseSchema, + 'OpenInterestResponse' + ); + expect(result.success).toBe(true); + }); + + it('should return valid data structure', () => { + const isArray = Array.isArray(openInterestResponse.data); + const isObject = typeof openInterestResponse.data === 'object' && openInterestResponse.data !== null; + + expect(isArray || isObject).toBe(true); + + if (isArray) { + expect(openInterestResponse.data.length).toBeGreaterThan(0); + console.log('Open Interest returned array with', openInterestResponse.data.length, 'data points'); + } else { + expect(Object.keys(openInterestResponse.data).length).toBeGreaterThan(0); + console.log('Open Interest returned aggregated object'); + } + }); + }); + + describe('Data Quality Validation', () => { + it('should have valid data structure', () => { + const isArray = Array.isArray(openInterestResponse.data); + const isObject = typeof openInterestResponse.data === 'object' && !isArray; + + expect(isArray || isObject).toBe(true); + + if (isArray) { + expect(openInterestResponse.data.length).toBeGreaterThan(0); + + // Validate array format + for (let i = 1; i < (openInterestResponse.data as [number, number][]).length; i++) { + const prevTimestamp = (openInterestResponse.data as [number, number][])[i - 1][0]; + const currTimestamp = (openInterestResponse.data as [number, number][])[i][0]; + expect(currTimestamp).toBeGreaterThanOrEqual(prevTimestamp); + } + + // Validate data points + (openInterestResponse.data as [number, number][]).slice(0, 20).forEach(([timestamp, value]) => { + expectValidNumber(timestamp); + expectValidNumber(value); + expectNonNegativeNumber(value); + expect(timestamp).toBeGreaterThan(0); + expect(value).toBeGreaterThan(0); + expect(value).toBeLessThan(1_000_000_000_000); + }); + + // Check freshness + const timestamps = (openInterestResponse.data as [number, number][]).map((point) => point[0]); + expectFreshData(timestamps, 86400 * 2); + } else { + const data = openInterestResponse.data as any; + expect(Object.keys(data).length).toBeGreaterThan(0); + + // Validate aggregated metrics + if (data.total24h !== undefined && data.total24h !== null) { + expectValidNumber(data.total24h); + expectNonNegativeNumber(data.total24h); + } + + if (data.total7d !== undefined && data.total7d !== null) { + expectValidNumber(data.total7d); + expectNonNegativeNumber(data.total7d); + } + + if (data.totalAllTime !== undefined && data.totalAllTime !== null) { + expectValidNumber(data.totalAllTime); + expectNonNegativeNumber(data.totalAllTime); + } + + // Validate protocols array + if (data.protocols !== undefined) { + expect(Array.isArray(data.protocols)).toBe(true); + expect(data.protocols.length).toBeGreaterThan(0); + } + + // Validate chart data + if (data.totalDataChart !== undefined && data.totalDataChart !== null) { + expect(Array.isArray(data.totalDataChart)).toBe(true); + + if (data.totalDataChart.length > 0) { + const firstPoint = data.totalDataChart[0] as [number, number]; + expect(Array.isArray(firstPoint)).toBe(true); + expect(firstPoint.length).toBe(2); + expectValidNumber(firstPoint[0]); + expectValidNumber(firstPoint[1]); + } + } + } + }); + }); + +}); + diff --git a/defi/api-tests/src/perps/schemas.ts b/defi/api-tests/src/perps/schemas.ts new file mode 100644 index 0000000000..b7c90cc7ae --- /dev/null +++ b/defi/api-tests/src/perps/schemas.ts @@ -0,0 +1,137 @@ +import { z } from 'zod'; + +// Open interest data point (tuple format) +export const openInterestPointSchema = z.tuple([z.number(), z.number()]); + +// Open interest response - can be either an array or an aggregated object +export const openInterestResponseSchema = z.union([ + z.array(openInterestPointSchema), + z.object({ + // Aggregated metrics + total24h: z.union([z.number(), z.null()]).optional(), + total48hto24h: z.union([z.number(), z.null()]).optional(), + total7d: z.union([z.number(), z.null()]).optional(), + total30d: z.union([z.number(), z.null()]).optional(), + totalAllTime: z.union([z.number(), z.null()]).optional(), + change_1d: z.union([z.number(), z.null()]).optional(), + protocols: z.array(z.any()).optional(), + allChains: z.array(z.string()).optional(), + totalDataChart: z.array(z.tuple([z.number(), z.number()])).optional(), + }).passthrough(), +]); + +// Chart item schema +export const perpsChartItemSchema = z.object({ + timestamp: z.number(), + dailyVolume: z.union([z.number(), z.null()]).optional(), + dailyOpenInterest: z.union([z.number(), z.null()]).optional(), + dailyFees: z.union([z.number(), z.null()]).optional(), + dailyRevenue: z.union([z.number(), z.null()]).optional(), + dailyPremiumVolume: z.union([z.number(), z.null()]).optional(), +}); + +// Overview item schema (for derivatives list) +export const perpsOverviewItemSchema = z.object({ + // Protocol metadata + defillamaId: z.union([z.string(), z.null()]).optional(), + name: z.string(), + displayName: z.union([z.string(), z.null()]).optional(), + module: z.union([z.string(), z.null()]).optional(), + category: z.union([z.string(), z.null()]).optional(), + logo: z.union([z.string(), z.null()]).optional(), + chains: z.array(z.string()).optional(), + protocolType: z.union([z.string(), z.null()]).optional(), + methodologyURL: z.union([z.string(), z.null()]).optional(), + methodology: z.union([z.string(), z.record(z.string(), z.string()), z.null()]).optional(), + slug: z.union([z.string(), z.null()]).optional(), + id: z.union([z.string(), z.null()]).optional(), + + // Metrics + total24h: z.union([z.number(), z.null()]).optional(), + total48hto24h: z.union([z.number(), z.null()]).optional(), + total7d: z.union([z.number(), z.null()]).optional(), + total30d: z.union([z.number(), z.null()]).optional(), + totalAllTime: z.union([z.number(), z.null()]).optional(), + change_1d: z.union([z.number(), z.null()]).optional(), + + // Chart data + totalDataChart: z.array(z.tuple([z.number(), z.number()])).optional(), + totalDataChartBreakdown: z.union([ + z.array(perpsChartItemSchema), + z.array(z.tuple([z.number(), z.number()])), + z.array(z.any()), + ]).optional(), + + // Additional fields + url: z.union([z.string(), z.null()]).optional(), + description: z.union([z.string(), z.null()]).optional(), + gecko_id: z.union([z.string(), z.null()]).optional(), + cmcId: z.union([z.string(), z.null()]).optional(), + twitter: z.union([z.string(), z.null()]).optional(), + treasury: z.union([z.string(), z.null()]).optional(), + governanceID: z.union([z.array(z.string()), z.null()]).optional(), + github: z.union([z.array(z.string()), z.null()]).optional(), + symbol: z.union([z.string(), z.null()]).optional(), + address: z.union([z.string(), z.null()]).optional(), + linkedProtocols: z.union([z.array(z.string()), z.null()]).optional(), + childProtocols: z.union([z.array(z.any()), z.null()]).optional(), + parentProtocol: z.union([z.string(), z.null()]).optional(), + forkedFrom: z.union([z.array(z.string()), z.null()]).optional(), + audits: z.union([z.string(), z.null()]).optional(), + audit_links: z.union([z.array(z.string()), z.null()]).optional(), + referralUrl: z.union([z.string(), z.null()]).optional(), + hasLabelBreakdown: z.union([z.boolean(), z.null()]).optional(), + previousNames: z.union([z.array(z.string()), z.null()]).optional(), + hallmarks: z.union([z.array(z.any()), z.null()]).optional(), + defaultChartView: z.union([z.string(), z.null()]).optional(), + doublecounted: z.union([z.boolean(), z.null()]).optional(), + breakdownMethodology: z.union([z.record(z.string(), z.any()), z.null()]).optional(), +}); + +// Aggregated overview response schema +export const perpsAggregatedOverviewSchema = z.object({ + // Aggregated metrics + total24h: z.union([z.number(), z.null()]).optional(), + total48hto24h: z.union([z.number(), z.null()]).optional(), + total7d: z.union([z.number(), z.null()]).optional(), + total14dto7d: z.union([z.number(), z.null()]).optional(), + total30d: z.union([z.number(), z.null()]).optional(), + total60dto30d: z.union([z.number(), z.null()]).optional(), + total1y: z.union([z.number(), z.null()]).optional(), + totalAllTime: z.union([z.number(), z.null()]).optional(), + total7DaysAgo: z.union([z.number(), z.null()]).optional(), + total30DaysAgo: z.union([z.number(), z.null()]).optional(), + + // Change percentages + change_1d: z.union([z.number(), z.null()]).optional(), + change_7d: z.union([z.number(), z.null()]).optional(), + change_1m: z.union([z.number(), z.null()]).optional(), + change_7dover7d: z.union([z.number(), z.null()]).optional(), + change_30dover30d: z.union([z.number(), z.null()]).optional(), + + // Protocols array + protocols: z.array(perpsOverviewItemSchema).optional(), + + // Chain data + allChains: z.array(z.string()).optional(), + chain: z.union([z.string(), z.null()]).optional(), + + // Chart data + totalDataChart: z.array(z.tuple([z.number(), z.number()])).optional(), + totalDataChartBreakdown: z.union([ + z.array(perpsChartItemSchema), + z.array(z.tuple([z.number(), z.number()])), + z.array(z.any()), + ]).optional(), + + // Breakdowns + breakdown24h: z.union([z.record(z.string(), z.any()), z.null()]).optional(), + breakdown30d: z.union([z.record(z.string(), z.any()), z.null()]).optional(), +}); + +// Overview response - Note: This endpoint returns aggregated data +export const perpsOverviewResponseSchema = perpsAggregatedOverviewSchema; + +// Summary response (single protocol with detailed data) +export const perpsSummaryResponseSchema = perpsOverviewItemSchema; + diff --git a/defi/api-tests/src/perps/types.ts b/defi/api-tests/src/perps/types.ts new file mode 100644 index 0000000000..edb00161fa --- /dev/null +++ b/defi/api-tests/src/perps/types.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { + openInterestPointSchema, + openInterestResponseSchema, + perpsChartItemSchema, + perpsOverviewItemSchema, + perpsOverviewResponseSchema, + perpsSummaryResponseSchema, +} from './schemas'; + +// Inferred types +export type OpenInterestPoint = z.infer; +export type OpenInterestResponse = z.infer; +export type PerpsChartItem = z.infer; +export type PerpsOverviewItem = z.infer; +export type PerpsOverviewResponse = z.infer; +export type PerpsSummaryResponse = z.infer; + +// Type guards +export function isOpenInterestResponse(data: any): data is OpenInterestResponse { + return ( + (Array.isArray(data) && data.every((item) => Array.isArray(item) && item.length === 2)) || + (data && typeof data === 'object' && !Array.isArray(data)) + ); +} + +export function isPerpsOverviewResponse(data: any): data is PerpsOverviewResponse { + return data && typeof data === 'object' && ('protocols' in data || 'total24h' in data); +} + +export function isPerpsSummaryResponse(data: any): data is PerpsSummaryResponse { + return data && typeof data === 'object' && 'name' in data; +} + diff --git a/defi/api-tests/src/stablecoins/asset.test.ts b/defi/api-tests/src/stablecoins/asset.test.ts new file mode 100644 index 0000000000..9d644ccf94 --- /dev/null +++ b/defi/api-tests/src/stablecoins/asset.test.ts @@ -0,0 +1,220 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { StablecoinAsset, isStablecoinAsset } from './types'; + +const apiClient = createApiClient(endpoints.STABLECOINS.BASE_URL); +import { stablecoinAssetSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectObjectResponse, + expectValidNumber, + expectNonNegativeNumber, + expectNonEmptyString, + expectValidTimestamp, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +describe('Stablecoins API - Asset', () => { + // Configure test assets - keep just one for speed, add more for thoroughness + const testAssets = ['1']; + const assetResponses: Record> = {}; + + beforeAll(async () => { + await Promise.all( + testAssets.map(async (asset) => { + const endpoint = endpoints.STABLECOINS.ASSET(asset); + if (endpoint && endpoint !== '') { + assetResponses[asset] = await apiClient.get(endpoint); + } + }) + ); + }, 60000); + + describe('Basic Response Validation', () => { + testAssets.forEach((asset) => { + describe(`Asset: ${asset}`, () => { + it('should return successful response with valid structure', () => { + const response = assetResponses[asset]; + + expectSuccessfulResponse(response); + expectObjectResponse(response); + expect(isStablecoinAsset(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const response = assetResponses[asset]; + + const result = validate(response.data, stablecoinAssetSchema, `StablecoinAsset-${asset}`); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should have required fields', () => { + const response = assetResponses[asset]; + + // Asset endpoint returns chainBalances/currentChainBalances, not chains array + const requiredFields = ['id', 'name', 'symbol']; + requiredFields.forEach((field) => { + expect(response.data).toHaveProperty(field); + }); + }); + + it('should have valid identifiers', () => { + const response = assetResponses[asset]; + + expectNonEmptyString(response.data.id); + expectNonEmptyString(response.data.name); + expectNonEmptyString(response.data.symbol); + // Verify ID matches what we requested + expect(response.data.id).toBe(asset); + }); + + it('should have valid chain balances', () => { + const response = assetResponses[asset]; + + // Asset endpoint returns chainBalances as an object, not an array + if (response.data.chainBalances) { + expect(typeof response.data.chainBalances).toBe('object'); + const chains = Object.keys(response.data.chainBalances); + expect(chains.length).toBeGreaterThan(0); + chains.forEach((chain) => { + expectNonEmptyString(chain); + }); + } + + if (response.data.currentChainBalances) { + expect(typeof response.data.currentChainBalances).toBe('object'); + } + }); + }); + }); + }); + + describe('Historical Chain Data', () => { + testAssets.forEach((asset) => { + describe(`Asset: ${asset}`, () => { + it('should have valid historical chain data if present', () => { + const response = assetResponses[asset]; + + if (response.data.historicalChainData) { + Object.entries(response.data.historicalChainData).forEach(([chain, data]) => { + expectNonEmptyString(chain); + expect(Array.isArray(data)).toBe(true); + + if (data.length > 0) { + // Sample-based testing - validate first 10 points + data.slice(0, 10).forEach((point) => { + expectValidTimestamp(point.date); + expectValidNumber(point.circulating); + expectNonNegativeNumber(point.circulating); + }); + } + }); + } + }); + + it('should have historical data in chronological order', () => { + const response = assetResponses[asset]; + + if (response.data.historicalChainData) { + Object.values(response.data.historicalChainData).forEach((data) => { + if (data.length > 1) { + const dates = data.map((p) => p.date); + const sortedDates = [...dates].sort((a, b) => a - b); + expect(dates).toEqual(sortedDates); + } + }); + } + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent asset gracefully', async () => { + const endpoint = endpoints.STABLECOINS.ASSET('NONEXISTENTASSETXYZ123'); + + const response = await apiClient.get(endpoint); + // API might return 200 with null data or 4xx status + if (response.status === 200) { + // Null data is acceptable for non-existent asset + expect(response.data === null || typeof response.data === 'object').toBe(true); + } else { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }); + + it('should handle asset with optional market cap', () => { + const response = assetResponses[testAssets[0]]; + + // marketCap is optional and may be null or undefined + if (response.data.marketCap === null || response.data.marketCap === undefined) { + expect(response.data.marketCap === null || response.data.marketCap === undefined).toBe(true); + } else { + // Market cap should be a valid number if present + expectValidNumber(response.data.marketCap); + expectNonNegativeNumber(response.data.marketCap); + } + }); + + it('should handle asset with chain balances', () => { + const response = assetResponses[testAssets[0]]; + + // Asset endpoint returns chainBalances as an object + if (response.data.chainBalances) { + expect(typeof response.data.chainBalances).toBe('object'); + const chains = Object.keys(response.data.chainBalances); + // Most stablecoins should have at least one chain + if (chains.length > 0) { + chains.forEach((chain) => { + expectNonEmptyString(chain); + }); + } + } + }); + }); + + describe('Static Metadata Validation', () => { + it('should have correct static metadata for USDT (Tether) (id:1)', () => { + const response = assetResponses['1']; + + // These fields never change for USDT (id:11) + expect(response.data.id).toBe('1'); + expect(response.data.name).toBe('Tether'); + expect(response.data.symbol).toBe('USDT'); + + // Optional but consistent fields + if (response.data.address) { + expect(response.data.address).toBe('0xdac17f958d2ee523a2206206994597c13d831ec7'); + } + + if (response.data.gecko_id) { + expect(response.data.gecko_id).toBe('tether'); + } + + if (response.data.cmcId) { + expect(response.data.cmcId).toBe('825'); + } + + if (response.data.pegType) { + expect(response.data.pegType).toBe('peggedUSD'); + } + + if (response.data.pegMechanism) { + expect(response.data.pegMechanism).toBe('fiat-backed'); + } + + if (response.data.url) { + expect(response.data.url).toBe('https://tether.to/'); + } + + if (response.data.twitter) { + expect(response.data.twitter).toBe('https://twitter.com/Tether_to'); + } + }); + }); +}); + diff --git a/defi/api-tests/src/stablecoins/chains.test.ts b/defi/api-tests/src/stablecoins/chains.test.ts new file mode 100644 index 0000000000..40612a4899 --- /dev/null +++ b/defi/api-tests/src/stablecoins/chains.test.ts @@ -0,0 +1,148 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { StablecoinChains, StablecoinChain, isStablecoinChains } from './types'; + +const apiClient = createApiClient(endpoints.STABLECOINS.BASE_URL); +import { stablecoinChainsArraySchema } from './schemas'; +import { + expectSuccessfulResponse, + expectArrayResponse, + expectNonEmptyArray, + expectValidNumber, + expectNonNegativeNumber, + expectNonEmptyString, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +describe('Stablecoins API - Chains', () => { + let chainsResponse: ApiResponse; + + beforeAll(async () => { + const endpoint = endpoints.STABLECOINS.CHAINS; + // Skip if endpoint is not available (free-only API not configured) + if (endpoint && endpoint !== '') { + chainsResponse = await apiClient.get(endpoint); + } + }); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(chainsResponse); + expectArrayResponse(chainsResponse); + expectNonEmptyArray(chainsResponse.data); + expect(isStablecoinChains(chainsResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate(chainsResponse.data, stablecoinChainsArraySchema, 'StablecoinChains'); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should have required fields in all chains', () => { + // Only name is required, tvl and tokens are optional + const requiredFields = ['name']; + // Sample-based testing - validate first 10 chains + chainsResponse.data.slice(0, 10).forEach((chain) => { + requiredFields.forEach((field) => { + expect(chain).toHaveProperty(field); + }); + }); + }); + + it('should have unique chain names', () => { + const chainNames = chainsResponse.data.map((c) => c.name); + expect(new Set(chainNames).size).toBe(chainNames.length); + }); + }); + + describe('Chain Data Validation', () => { + it('should have valid chain properties', () => { + // Sample-based testing - validate first 10 chains + chainsResponse.data.slice(0, 10).forEach((chain) => { + expectNonEmptyString(chain.name); + + // TVL and tokens are optional fields + if (chain.tvl !== undefined) { + expectValidNumber(chain.tvl); + expectNonNegativeNumber(chain.tvl); + } + if (chain.tokens !== undefined) { + expectValidNumber(chain.tokens); + expectNonNegativeNumber(chain.tokens); + expect(Number.isInteger(chain.tokens)).toBe(true); + } + }); + }); + + it('should have valid TVL values', () => { + const chainsWithTvl = chainsResponse.data.filter((c) => c.tvl !== undefined && c.tvl > 0); + + // Only test if there are chains with TVL data + if (chainsWithTvl.length > 0) { + // Sample-based testing - validate first 10 chains with TVL + chainsWithTvl.slice(0, 10).forEach((chain) => { + expectValidNumber(chain.tvl!); + expectNonNegativeNumber(chain.tvl!); + expect(chain.tvl!).toBeLessThan(10_000_000_000_000); + }); + } + }); + + it('should have valid percentage changes when present', () => { + const chainsWithChanges = chainsResponse.data.filter( + (c) => c.change_1d !== null || c.change_7d !== null || c.change_30d !== null + ); + + if (chainsWithChanges.length > 0) { + // Sample-based testing - validate first 10 chains with changes + chainsWithChanges.slice(0, 10).forEach((chain) => { + if (chain.change_1d !== null && chain.change_1d !== undefined) { + expectValidNumber(chain.change_1d); + } + if (chain.change_7d !== null && chain.change_7d !== undefined) { + expectValidNumber(chain.change_7d); + } + if (chain.change_30d !== null && chain.change_30d !== undefined) { + expectValidNumber(chain.change_30d); + } + }); + } + }); + }); + + describe('Edge Cases', () => { + it('should handle chains with zero TVL', () => { + const zeroTvl = chainsResponse.data.filter((c) => c.tvl === 0); + zeroTvl.forEach((chain) => { + expect(chain.tvl).toBe(0); + expectNonEmptyString(chain.name); + }); + }); + + it('should handle chains with zero tokens', () => { + const zeroTokens = chainsResponse.data.filter((c) => c.tokens === 0); + zeroTokens.forEach((chain) => { + expect(chain.tokens).toBe(0); + expectNonEmptyString(chain.name); + }); + }); + + it('should handle chains with null percentage changes', () => { + const nullChanges = chainsResponse.data.filter( + (c) => c.change_1d === null || c.change_7d === null || c.change_30d === null + ); + + if (nullChanges.length > 0) { + // Sample-based testing - validate first 5 chains with null changes + nullChanges.slice(0, 5).forEach((chain) => { + expectNonEmptyString(chain.name); + expectValidNumber(chain.tvl); + }); + } + }); + }); +}); diff --git a/defi/api-tests/src/stablecoins/charts.test.ts b/defi/api-tests/src/stablecoins/charts.test.ts new file mode 100644 index 0000000000..35757faac1 --- /dev/null +++ b/defi/api-tests/src/stablecoins/charts.test.ts @@ -0,0 +1,206 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { StablecoinCharts, StablecoinChartPoint, isStablecoinCharts } from './types'; + +const apiClient = createApiClient(endpoints.STABLECOINS.BASE_URL); +import { stablecoinChartsArraySchema } from './schemas'; +import { + expectSuccessfulResponse, + expectArrayResponse, + expectNonEmptyArray, + expectValidNumber, + expectNonNegativeNumber, + expectValidTimestamp, + expectFreshData, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +describe('Stablecoins API - Charts (All Chains)', () => { + let chartsAllResponse: ApiResponse; + + beforeAll(async () => { + const endpoint = endpoints.STABLECOINS.CHARTS_ALL; + // Skip if endpoint is not available (free-only API not configured) + if (endpoint && endpoint !== '') { + chartsAllResponse = await apiClient.get(endpoint); + } + }); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(chartsAllResponse); + expectArrayResponse(chartsAllResponse); + expectNonEmptyArray(chartsAllResponse.data); + expect(isStablecoinCharts(chartsAllResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate(chartsAllResponse.data, stablecoinChartsArraySchema, 'StablecoinCharts-All'); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should have valid data points structure', () => { + // Sample-based testing - validate first 10 data points + chartsAllResponse.data.slice(0, 10).forEach((point) => { + // Date is a string timestamp from API, should be convertible to number + const dateNum = typeof point.date === 'string' ? Number(point.date) : point.date; + expect(typeof dateNum).toBe('number'); + expect(isNaN(dateNum)).toBe(false); + expectValidTimestamp(dateNum); + + // totalCirculating is an object with pegType keys + expect(typeof point.totalCirculating).toBe('object'); + expect(point.totalCirculating).not.toBeNull(); + Object.values(point.totalCirculating).forEach((value) => { + expectValidNumber(value); + expectNonNegativeNumber(value); + }); + }); + }); + + it('should have data points in chronological order', () => { + if (chartsAllResponse.data.length > 1) { + const dates = chartsAllResponse.data.map((p) => p.date); + const sortedDates = [...dates].sort((a, b) => a - b); + expect(dates).toEqual(sortedDates); + } + }); + + it('should have valid circulating breakdown when present', () => { + const withBreakdown = chartsAllResponse.data.filter( + (p) => p.circulating && Object.keys(p.circulating).length > 0 + ); + + if (withBreakdown.length > 0) { + // Sample-based testing - validate first 5 data points with breakdown + withBreakdown.slice(0, 5).forEach((point) => { + Object.entries(point.circulating!).forEach(([chain, amount]) => { + expect(typeof chain).toBe('string'); + expect(chain.length).toBeGreaterThan(0); + expectValidNumber(amount); + expectNonNegativeNumber(amount); + }); + }); + } + }); + + it('should have fresh data (most recent timestamp within 1 day)', () => { + if (chartsAllResponse.data.length === 0) return; // Skip if empty + + const timestamps = chartsAllResponse.data.map((point) => point.date); + expectFreshData(timestamps); + }); + }); +}); + +describe('Stablecoins API - Charts (Specific Chain)', () => { + // Configure test chains - keep just one for speed, add more for thoroughness + const testChains = ['ethereum']; + // const testChains = ['ethereum', 'bsc', 'polygon']; + const chainChartsResponses: Record> = {}; + + beforeAll(async () => { + // Fetch all test chains in parallel once + await Promise.all( + testChains.map(async (chain) => { + const endpoint = endpoints.STABLECOINS.CHARTS_BY_CHAIN(chain); + // Skip if endpoint is not available (free-only API not configured) + if (endpoint && endpoint !== '') { + chainChartsResponses[chain] = await apiClient.get(endpoint); + } + }) + ); + }, 60000); + + describe('Basic Response Validation', () => { + testChains.forEach((chain) => { + describe(`Chain: ${chain}`, () => { + it('should return successful response with valid structure', () => { + const response = chainChartsResponses[chain]; + expectSuccessfulResponse(response); + expectArrayResponse(response); + expect(isStablecoinCharts(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const response = chainChartsResponses[chain]; + const result = validate(response.data, stablecoinChartsArraySchema, `StablecoinCharts-${chain}`); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should have valid data points structure', () => { + const response = chainChartsResponses[chain]; + if (response.data.length > 0) { + // Sample-based testing - validate first 10 data points + response.data.slice(0, 10).forEach((point) => { + // Date is a string timestamp from API, should be convertible to number + const dateNum = typeof point.date === 'string' ? Number(point.date) : point.date; + expect(typeof dateNum).toBe('number'); + expect(isNaN(dateNum)).toBe(false); + expectValidTimestamp(dateNum); + + // totalCirculating is an object with pegType keys + expect(typeof point.totalCirculating).toBe('object'); + expect(point.totalCirculating).not.toBeNull(); + Object.values(point.totalCirculating).forEach((value) => { + expectValidNumber(value); + expectNonNegativeNumber(value); + }); + }); + } + }); + + it('should have data points in chronological order', () => { + const response = chainChartsResponses[chain]; + if (response.data.length > 1) { + const dates = response.data.map((p) => p.date); + const sortedDates = [...dates].sort((a, b) => a - b); + expect(dates).toEqual(sortedDates); + } + }); + + it('should have fresh data (most recent timestamp within 1 day)', () => { + const response = chainChartsResponses[chain]; + if (response.data.length === 0) return; // Skip if empty + + const timestamps = response.data.map((point) => point.date); + expectFreshData(timestamps); + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent chain gracefully', async () => { + const endpoint = endpoints.STABLECOINS.CHARTS_BY_CHAIN('non-existent-chain-xyz-123'); + + const response = await apiClient.get(endpoint); + + // API might return 200 with empty array, error object, or 4xx status + if (response.status === 200) { + // Accept either an array (empty or with data) or an error object/string + const isValidResponse = Array.isArray(response.data) || + typeof response.data === 'object' || + typeof response.data === 'string'; + expect(isValidResponse).toBe(true); + } else { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }); + + it('should handle empty array for new chains', () => { + const response = chainChartsResponses[testChains[0]]; + + if (response.data.length === 0) { + expect(Array.isArray(response.data)).toBe(true); + } + }); + }); +}); diff --git a/defi/api-tests/src/stablecoins/dominance.test.ts b/defi/api-tests/src/stablecoins/dominance.test.ts new file mode 100644 index 0000000000..9b61e9556e --- /dev/null +++ b/defi/api-tests/src/stablecoins/dominance.test.ts @@ -0,0 +1,175 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { StablecoinDominanceArray, isStablecoinDominanceArray } from './types'; + +const apiClient = createApiClient(endpoints.STABLECOINS.BASE_URL); +import { stablecoinDominanceArraySchema } from './schemas'; +import { + expectSuccessfulResponse, + expectArrayResponse, + expectNonEmptyArray, + expectValidNumber, + expectNonNegativeNumber, + expectValidTimestamp, + expectFreshData, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +describe('Stablecoins API - Dominance', () => { + // Configure test chains - keep just one for speed, add more for thoroughness + const testChains = ['Ethereum']; + // const testChains = ['Ethereum', 'BSC', 'Polygon', 'Arbitrum', 'Avalanche']; + const dominanceResponses: Record> = {}; + + beforeAll(async () => { + // Fetch all test chains in parallel once + await Promise.all( + testChains.map(async (chain) => { + const endpoint = endpoints.STABLECOINS.DOMINANCE(chain); + dominanceResponses[chain] = await apiClient.get(endpoint); + }) + ); + }, 60000); + + describe('Basic Response Validation', () => { + testChains.forEach((chain) => { + describe(`Chain: ${chain}`, () => { + it('should return successful response with valid structure', () => { + const response = dominanceResponses[chain]; + expectSuccessfulResponse(response); + expectArrayResponse(response); + expect(isStablecoinDominanceArray(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const response = dominanceResponses[chain]; + const result = validate(response.data, stablecoinDominanceArraySchema, `StablecoinDominance-${chain}`); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should have required fields in all data points', () => { + const response = dominanceResponses[chain]; + expect(response).toBeDefined(); + + if (response.data.length > 0) { + const requiredFields = ['date', 'totalCirculatingUSD']; + // Sample-based testing - validate first 10 data points + response.data.slice(0, 10).forEach((point) => { + requiredFields.forEach((field) => { + expect(point).toHaveProperty(field); + }); + }); + } + }); + + it('should have valid timestamps', () => { + const response = dominanceResponses[chain]; + expect(response).toBeDefined(); + + if (response.data.length > 0) { + // Sample-based testing - validate first 10 data points + response.data.slice(0, 10).forEach((point) => { + // Date is a string timestamp from API, should be convertible to number + const dateNum = typeof point.date === 'string' ? Number(point.date) : point.date; + expect(typeof dateNum).toBe('number'); + expect(isNaN(dateNum)).toBe(false); + expectValidTimestamp(dateNum); + }); + } + }); + + it('should have data points in chronological order', () => { + const response = dominanceResponses[chain]; + + if (response.data.length > 1) { + const dates = response.data.map((p) => p.date); + const sortedDates = [...dates].sort((a, b) => a - b); + expect(dates).toEqual(sortedDates); + } + }); + + it('should have fresh data (most recent timestamp within 1 day)', () => { + const response = dominanceResponses[chain]; + + if (response.data.length > 0) { + const timestamps = response.data.map((point) => { + // Convert string timestamp to number if needed + return typeof point.date === 'string' ? Number(point.date) : point.date; + }); + expectFreshData(timestamps, 86400); // 1 day in seconds + } + }); + }); + }); + }); + + describe('Dominance Data Validation', () => { + testChains.forEach((chain) => { + describe(`Chain: ${chain}`, () => { + it('should have valid total circulating USD values', () => { + const response = dominanceResponses[chain]; + expect(response).toBeDefined(); + + if (response.data.length > 0) { + // Sample-based testing - validate first 10 data points + response.data.slice(0, 10).forEach((point) => { + // totalCirculatingUSD is an object with pegType keys + expect(typeof point.totalCirculatingUSD).toBe('object'); + expect(point.totalCirculatingUSD).not.toBeNull(); + + Object.entries(point.totalCirculatingUSD).forEach(([pegType, amount]) => { + expect(typeof pegType).toBe('string'); + expect(pegType.length).toBeGreaterThan(0); + expectValidNumber(amount); + expectNonNegativeNumber(amount); + expect(amount).toBeLessThan(10_000_000_000_000); + }); + }); + } + }); + + it('should have valid greatestMcap when present', () => { + const response = dominanceResponses[chain]; + expect(response).toBeDefined(); + + if (response.data.length > 0) { + // Sample-based testing - validate first 10 data points + response.data.slice(0, 10).forEach((point) => { + if (point.greatestMcap) { + expect(typeof point.greatestMcap).toBe('object'); + expect(point.greatestMcap.symbol).toBeDefined(); + expect(typeof point.greatestMcap.symbol).toBe('string'); + expectValidNumber(point.greatestMcap.mcap); + expectNonNegativeNumber(point.greatestMcap.mcap); + } + }); + } + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent chain gracefully', async () => { + const endpoint = endpoints.STABLECOINS.DOMINANCE('non-existent-chain-xyz-123'); + + const response = await apiClient.get(endpoint); + expect(response.status).toBeGreaterThanOrEqual(200); + expect(response.data).toBeNull(); + }); + + it('should handle empty array for new chains', () => { + const response = dominanceResponses[testChains[0]]; + expect(response).toBeDefined(); + + if (response.data.length === 0) { + console.log('response', response); + expect(Array.isArray(response.data)).toBe(true); + } + }); + }); +}); diff --git a/defi/api-tests/src/stablecoins/list.test.ts b/defi/api-tests/src/stablecoins/list.test.ts new file mode 100644 index 0000000000..b3dff9df60 --- /dev/null +++ b/defi/api-tests/src/stablecoins/list.test.ts @@ -0,0 +1,169 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; + +const apiClient = createApiClient(endpoints.STABLECOINS.BASE_URL); +import { StablecoinsListResponse, Stablecoin, isStablecoinsListResponse } from './types'; +import { stablecoinsListResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectObjectResponse, + expectNonEmptyArray, + expectValidNumber, + expectNonNegativeNumber, + expectNonEmptyString, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +describe('Stablecoins API - List', () => { + let stablecoinsResponse: ApiResponse; + + beforeAll(async () => { + const endpoint = endpoints.STABLECOINS.LIST; + // Skip if endpoint is not available (free-only API not configured) + if (endpoint && endpoint !== '') { + stablecoinsResponse = await apiClient.get(endpoint); + } + }); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(stablecoinsResponse); + expectObjectResponse(stablecoinsResponse); + expect(isStablecoinsListResponse(stablecoinsResponse.data)).toBe(true); + expect(Array.isArray(stablecoinsResponse.data.peggedAssets)).toBe(true); + expectNonEmptyArray(stablecoinsResponse.data.peggedAssets); + }); + + it('should validate against Zod schema', () => { + const result = validate(stablecoinsResponse.data, stablecoinsListResponseSchema, 'Stablecoins'); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should have required fields in all stablecoins', () => { + const requiredFields = ['id', 'name', 'symbol', 'chains']; + // Sample-based testing - validate first 10 stablecoins + stablecoinsResponse.data.peggedAssets.slice(0, 10).forEach((stablecoin) => { + requiredFields.forEach((field) => { + expect(stablecoin).toHaveProperty(field); + }); + }); + }); + + it('should have unique identifiers', () => { + const ids = stablecoinsResponse.data.peggedAssets.map((s) => s.id); + // IDs should be unique + expect(new Set(ids).size).toBe(ids.length); + + // Note: Symbols are NOT unique - multiple stablecoins can share the same symbol + // For example, multiple USDT variants, bridged tokens, etc. + const symbols = stablecoinsResponse.data.peggedAssets.map((s) => s.symbol); + expect(symbols.length).toBeGreaterThan(0); + }); + }); + + describe('Stablecoin Data Validation', () => { + it('should have valid stablecoin properties', () => { + // Sample-based testing - validate first 10 stablecoins + stablecoinsResponse.data.peggedAssets.slice(0, 10).forEach((stablecoin) => { + expectNonEmptyString(stablecoin.id); + expectNonEmptyString(stablecoin.name); + expectNonEmptyString(stablecoin.symbol); + expect(Array.isArray(stablecoin.chains)).toBe(true); + expect(stablecoin.chains.length).toBeGreaterThan(0); + + stablecoin.chains.forEach((chain) => { + expectNonEmptyString(chain); + }); + }); + }); + + it('should have valid market cap values when present', () => { + const withMarketCap = stablecoinsResponse.data.peggedAssets.filter( + (s) => s.marketCap !== null && s.marketCap !== undefined + ); + + if (withMarketCap.length > 0) { + // Sample-based testing - validate first 10 stablecoins with market cap + withMarketCap.slice(0, 10).forEach((stablecoin) => { + expectValidNumber(stablecoin.marketCap!); + expectNonNegativeNumber(stablecoin.marketCap!); + }); + } + }); + + it('should have valid price values when present', () => { + const withPrice = stablecoinsResponse.data.peggedAssets.filter( + (s) => s.price !== null && s.price !== undefined + ); + + if (withPrice.length > 0) { + // Sample-based testing - validate first 10 stablecoins with price + withPrice.slice(0, 10).forEach((stablecoin) => { + expectValidNumber(stablecoin.price!); + expectNonNegativeNumber(stablecoin.price!); + }); + } + }); + + it('should have valid circulating amounts when present', () => { + const withCirculating = stablecoinsResponse.data.peggedAssets.filter( + (s) => s.circulating && Object.keys(s.circulating).length > 0 + ); + + if (withCirculating.length > 0) { + // Sample-based testing - validate first 5 stablecoins with circulating data + withCirculating.slice(0, 5).forEach((stablecoin) => { + Object.entries(stablecoin.circulating!).forEach(([chain, amount]) => { + expectNonEmptyString(chain); + expectValidNumber(amount); + expectNonNegativeNumber(amount); + }); + }); + } + }); + }); + + describe('Edge Cases', () => { + it('should handle stablecoins with null market cap', () => { + const nullMarketCap = stablecoinsResponse.data.peggedAssets.filter((s) => s.marketCap === null); + nullMarketCap.forEach((stablecoin) => { + expect(stablecoin.marketCap).toBeNull(); + expectNonEmptyString(stablecoin.name); + }); + }); + + it('should handle stablecoins with empty chains array', () => { + const emptyChains = stablecoinsResponse.data.peggedAssets.filter((s) => s.chains.length === 0); + if (emptyChains.length > 0) { + emptyChains.forEach((stablecoin) => { + expect(Array.isArray(stablecoin.chains)).toBe(true); + }); + } + }); + + it('should have valid optional metadata fields', () => { + // Sample-based testing - validate first 10 stablecoins + stablecoinsResponse.data.peggedAssets.slice(0, 10).forEach((stablecoin) => { + if (stablecoin.url) { + expect(stablecoin.url).toMatch(/^https?:\/\//); + } + + if (stablecoin.twitter) { + expect(typeof stablecoin.twitter).toBe('string'); + } + + if (stablecoin.audit_links) { + expect(Array.isArray(stablecoin.audit_links)).toBe(true); + stablecoin.audit_links.forEach((link) => { + expectNonEmptyString(link); + }); + } + }); + }); + }); +}); + diff --git a/defi/api-tests/src/stablecoins/prices.test.ts b/defi/api-tests/src/stablecoins/prices.test.ts new file mode 100644 index 0000000000..f710b67ace --- /dev/null +++ b/defi/api-tests/src/stablecoins/prices.test.ts @@ -0,0 +1,126 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { StablecoinPrices, StablecoinPrice, isStablecoinPrices } from './types'; + +const apiClient = createApiClient(endpoints.STABLECOINS.BASE_URL); +import { stablecoinPricesArraySchema } from './schemas'; +import { + expectSuccessfulResponse, + expectArrayResponse, + expectNonEmptyArray, + expectValidNumber, + expectNonNegativeNumber, + expectValidTimestamp, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +describe('Stablecoins API - Prices', () => { + let pricesResponse: ApiResponse; + + beforeAll(async () => { + const endpoint = endpoints.STABLECOINS.PRICES; + // Skip if endpoint is not available (free-only API not configured) + if (endpoint && endpoint !== '') { + pricesResponse = await apiClient.get(endpoint); + } + }); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(pricesResponse); + expectArrayResponse(pricesResponse); + expectNonEmptyArray(pricesResponse.data); + expect(isStablecoinPrices(pricesResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate(pricesResponse.data, stablecoinPricesArraySchema, 'StablecoinPrices'); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should have required fields in all price points', () => { + const requiredFields = ['date', 'prices']; + // Sample-based testing - validate first 10 price points + pricesResponse.data.slice(0, 10).forEach((pricePoint) => { + requiredFields.forEach((field) => { + expect(pricePoint).toHaveProperty(field); + }); + }); + }); + + it('should have data points in chronological order', () => { + if (pricesResponse.data.length > 1) { + const dates = pricesResponse.data.map((p) => p.date); + const sortedDates = [...dates].sort((a, b) => a - b); + expect(dates).toEqual(sortedDates); + } + }); + }); + + describe('Price Data Validation', () => { + it('should have valid timestamps', () => { + // Filter out invalid timestamps (0 or negative) and validate the rest + const validTimestamps = pricesResponse.data.filter((p) => p.date > 0); + expect(validTimestamps.length).toBeGreaterThan(0); + + // Sample-based testing - validate first 10 valid price points + validTimestamps.slice(0, 10).forEach((pricePoint) => { + expectValidTimestamp(pricePoint.date); + }); + }); + + it('should have valid price objects', () => { + // Sample-based testing - validate first 10 price points + pricesResponse.data.slice(0, 10).forEach((pricePoint) => { + expect(typeof pricePoint.prices).toBe('object'); + expect(pricePoint.prices).not.toBeNull(); + expect(Object.keys(pricePoint.prices).length).toBeGreaterThan(0); + }); + }); + + it('should have valid price values', () => { + // Sample-based testing - validate first 10 price points + pricesResponse.data.slice(0, 10).forEach((pricePoint) => { + Object.entries(pricePoint.prices).forEach(([symbol, price]) => { + expect(typeof symbol).toBe('string'); + expect(symbol.length).toBeGreaterThan(0); + expectValidNumber(price); + expectNonNegativeNumber(price); + expect(price).toBeLessThan(1000000); + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty prices object', () => { + const emptyPrices = pricesResponse.data.filter( + (p) => Object.keys(p.prices).length === 0 + ); + + if (emptyPrices.length > 0) { + emptyPrices.forEach((pricePoint) => { + expect(typeof pricePoint.prices).toBe('object'); + expect(pricePoint.prices).not.toBeNull(); + }); + } + }); + + it('should have reasonable timestamp range', () => { + // Filter out invalid timestamps (0 or negative) + const validData = pricesResponse.data.filter((p) => p.date > 0); + + if (validData.length > 0) { + const firstDate = validData[0].date; + const lastDate = validData[validData.length - 1].date; + expect(lastDate).toBeGreaterThanOrEqual(firstDate); + expect(firstDate).toBeGreaterThan(1262304000); // After Jan 1, 2010 + expect(lastDate).toBeLessThan(Date.now() / 1000 + 86400); // Not more than 1 day in future + } + }); + }); +}); diff --git a/defi/api-tests/src/stablecoins/schemas.ts b/defi/api-tests/src/stablecoins/schemas.ts new file mode 100644 index 0000000000..f2eb86eca5 --- /dev/null +++ b/defi/api-tests/src/stablecoins/schemas.ts @@ -0,0 +1,168 @@ +// Zod schemas for Stablecoins API endpoints +// These define the runtime validation and TypeScript types + +import { z } from 'zod'; + +// ============================================================================ +// Base Schemas +// ============================================================================ + +export const stablecoinChainDataSchema = z.record(z.string(), z.number().finite().nonnegative()); + +// ============================================================================ +// Stablecoin Schema (from /stablecoins/stablecoins endpoint) +// ============================================================================ + +export const stablecoinSchema = z.object({ + id: z.string(), + name: z.string(), + symbol: z.string(), + address: z.string().optional(), + gecko_id: z.string().nullable().optional(), + cmcId: z.string().nullable().optional(), + chains: z.array(z.string()), + marketCap: z.number().finite().nonnegative().nullable().optional(), + circulating: z.record(z.string(), z.number().finite().nonnegative()).optional(), + // circulatingPrevDay/Week/Month can be either a record or a number (API inconsistency) + circulatingPrevDay: z.union([ + z.record(z.string(), z.number().finite().nonnegative()), + z.number().finite().nonnegative() + ]).optional(), + circulatingPrevWeek: z.union([ + z.record(z.string(), z.number().finite().nonnegative()), + z.number().finite().nonnegative() + ]).optional(), + circulatingPrevMonth: z.union([ + z.record(z.string(), z.number().finite().nonnegative()), + z.number().finite().nonnegative() + ]).optional(), + // price can be either a number or a string (API inconsistency) + price: z.union([ + z.number().finite().nonnegative(), + z.string() + ]).nullable().optional(), + pegType: z.string().optional(), + pegMechanism: z.string().optional(), + url: z.string().optional(), + twitter: z.string().optional(), + audit_links: z.array(z.string()).optional(), + auditLinks: z.array(z.string()).optional(), + description: z.string().nullable().optional(), + priceSource: z.string().nullable().optional(), + onCoinGecko: z.string().nullable().optional(), +}); + +export const stablecoinsArraySchema = z.array(stablecoinSchema); + +// Wrapper schema for the list endpoint response +export const stablecoinsListResponseSchema = z.object({ + peggedAssets: z.array(stablecoinSchema), +}); + +// ============================================================================ +// Stablecoin Asset Schema (from /stablecoins/stablecoin/{asset} endpoint) +// ============================================================================ + +// Chain balance structure for single asset endpoint +const chainBalanceSchema = z.object({ + tokens: z.union([ + z.array(z.any()), + z.record(z.string(), z.any()) + ]).optional(), + total: z.number().optional(), +}).passthrough(); // Allow additional fields + +export const stablecoinAssetSchema = z.object({ + id: z.string(), + name: z.string(), + symbol: z.string(), + address: z.string().optional(), + gecko_id: z.string().nullable().optional(), + cmcId: z.string().nullable().optional(), + pegType: z.string().optional(), + pegMechanism: z.string().optional(), + price: z.union([ + z.number().finite().nonnegative(), + z.string() + ]).nullable().optional(), + priceSource: z.string().nullable().optional(), + description: z.string().nullable().optional(), + url: z.string().optional(), + twitter: z.string().optional(), + wiki: z.string().optional(), + audit_links: z.array(z.string()).optional(), + auditLinks: z.array(z.string()).optional(), + onCoinGecko: z.string().nullable().optional(), + mintRedeemDescription: z.string().optional(), + // Asset endpoint specific fields (not chains array!) + chainBalances: z.record(z.string(), chainBalanceSchema).optional(), + currentChainBalances: z.record(z.string(), chainBalanceSchema).optional(), + tokens: z.array(z.any()).optional(), // Array of token objects + historicalChainData: z.record(z.string(), z.array(z.object({ + date: z.number(), + circulating: z.number().finite().nonnegative(), + }))).optional(), + marketCap: z.number().finite().nonnegative().nullable().optional(), + circulating: z.record(z.string(), z.number().finite().nonnegative()).optional(), +}); + +// ============================================================================ +// Stablecoin Chains Schema (from /stablecoins/stablecoinchains endpoint) +// ============================================================================ + +export const stablecoinChainSchema = z.object({ + name: z.string(), + tvl: z.number().finite().nonnegative().optional(), // Some chains don't have TVL + tokens: z.number().int().nonnegative().optional(), // Some chains don't have token count + change_1d: z.number().nullable().optional(), + change_7d: z.number().nullable().optional(), + change_30d: z.number().nullable().optional(), + gecko_id: z.string().nullable().optional(), + tokenSymbol: z.string().nullable().optional(), + totalCirculatingUSD: z.record(z.string(), z.number().finite().nonnegative()).optional(), +}); + +export const stablecoinChainsArraySchema = z.array(stablecoinChainSchema); + +// ============================================================================ +// Stablecoin Prices Schema (from /stablecoins/stablecoinprices endpoint) +// ============================================================================ + +export const stablecoinPriceSchema = z.object({ + date: z.number(), + // Prices can be numbers or strings (for some coins like frankencoin) + prices: z.record(z.string(), z.union([z.number().finite().nonnegative(), z.string()])), +}); + +export const stablecoinPricesArraySchema = z.array(stablecoinPriceSchema); + +// ============================================================================ +// Stablecoin Charts Schema (from /stablecoins/stablecoincharts endpoints) +// ============================================================================ + +export const stablecoinChartPointSchema = z.object({ + date: z.union([z.number(), z.string().transform(Number)]), // API returns string timestamp + totalCirculating: z.record(z.string(), z.number().finite().nonnegative()), // API returns object like { "peggedUSD": 123 } + totalCirculatingUSD: z.record(z.string(), z.number().finite().nonnegative()).optional(), + totalMintedUSD: z.record(z.string(), z.number().finite().nonnegative()).optional(), + circulating: z.record(z.string(), z.number().finite().nonnegative()).optional(), +}); + +export const stablecoinChartsArraySchema = z.array(stablecoinChartPointSchema); + +// ============================================================================ +// Stablecoin Dominance Schema (from /stablecoins/stablecoindominance/{chain} endpoint) +// ============================================================================ + +export const stablecoinDominanceSchema = z.object({ + date: z.union([z.number(), z.string().transform(Number)]), // API returns string timestamp + totalCirculatingUSD: z.record(z.string(), z.number().finite().nonnegative()), // API returns object like { "peggedUSD": 123 } + greatestMcap: z.object({ + gecko_id: z.string().nullable().optional(), + symbol: z.string(), + mcap: z.number().finite().nonnegative(), + }).optional() +}); + +export const stablecoinDominanceArraySchema = z.array(stablecoinDominanceSchema); + diff --git a/defi/api-tests/src/stablecoins/types.ts b/defi/api-tests/src/stablecoins/types.ts new file mode 100644 index 0000000000..c1eb1f9fac --- /dev/null +++ b/defi/api-tests/src/stablecoins/types.ts @@ -0,0 +1,68 @@ +// TypeScript types for Stablecoins API endpoints +// These types are inferred from Zod schemas to ensure consistency + +import { z } from 'zod'; +import { + stablecoinSchema, + stablecoinsArraySchema, + stablecoinsListResponseSchema, + stablecoinAssetSchema, + stablecoinChainSchema, + stablecoinChainsArraySchema, + stablecoinPriceSchema, + stablecoinPricesArraySchema, + stablecoinChartPointSchema, + stablecoinChartsArraySchema, + stablecoinDominanceSchema, + stablecoinDominanceArraySchema, +} from './schemas'; + +export type Stablecoin = z.infer; +export type Stablecoins = z.infer; +export type StablecoinsListResponse = z.infer; +export type StablecoinAsset = z.infer; +export type StablecoinChain = z.infer; +export type StablecoinChains = z.infer; +export type StablecoinPrice = z.infer; +export type StablecoinPrices = z.infer; +export type StablecoinChartPoint = z.infer; +export type StablecoinCharts = z.infer; +export type StablecoinDominance = z.infer; +export type StablecoinDominanceArray = z.infer; + +// ============================================================================ +// Type Guards +// ============================================================================ + +export function isStablecoin(data: unknown): data is Stablecoin { + return stablecoinSchema.safeParse(data).success; +} + +export function isStablecoins(data: unknown): data is Stablecoins { + return stablecoinsArraySchema.safeParse(data).success; +} + +export function isStablecoinsListResponse(data: unknown): data is StablecoinsListResponse { + return stablecoinsListResponseSchema.safeParse(data).success; +} + +export function isStablecoinAsset(data: unknown): data is StablecoinAsset { + return stablecoinAssetSchema.safeParse(data).success; +} + +export function isStablecoinChains(data: unknown): data is StablecoinChains { + return stablecoinChainsArraySchema.safeParse(data).success; +} + +export function isStablecoinPrices(data: unknown): data is StablecoinPrices { + return stablecoinPricesArraySchema.safeParse(data).success; +} + +export function isStablecoinCharts(data: unknown): data is StablecoinCharts { + return stablecoinChartsArraySchema.safeParse(data).success; +} + +export function isStablecoinDominanceArray(data: unknown): data is StablecoinDominanceArray { + return stablecoinDominanceArraySchema.safeParse(data).success; +} + diff --git a/defi/api-tests/src/tvl/chainAssets.test.ts b/defi/api-tests/src/tvl/chainAssets.test.ts new file mode 100644 index 0000000000..9d543b9178 --- /dev/null +++ b/defi/api-tests/src/tvl/chainAssets.test.ts @@ -0,0 +1,122 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { ChainAssets, isChainAssets } from './types'; +import { chainAssetsSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectObjectResponse, + expectValidNumber, + expectValidTimestamp, + expectNonEmptyString, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.TVL.BASE_URL); +const TVL_ENDPOINTS = endpoints.TVL; + +describe('TVL API - Chain Assets', () => { + let chainAssetsResponse: ApiResponse; + let chainKeys: string[]; + + beforeAll(async () => { + chainAssetsResponse = await apiClient.get(TVL_ENDPOINTS.CHAIN_ASSETS); + // Extract chain keys once (filter out metadata fields) + chainKeys = Object.keys(chainAssetsResponse.data).filter(key => key !== 'timestamp'); + }); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(chainAssetsResponse); + expectObjectResponse(chainAssetsResponse); + expect(isChainAssets(chainAssetsResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate(chainAssetsResponse.data, chainAssetsSchema, 'ChainAssets'); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors:', result.errors); + } + }); + + it('should have timestamp field', () => { + expect(chainAssetsResponse.data).toHaveProperty('timestamp'); + expectValidTimestamp(chainAssetsResponse.data.timestamp); + }); + + it('should have chain data', () => { + expect(chainKeys.length).toBeGreaterThan(0); + expect(chainKeys.length).toBeGreaterThan(10); // Should have multiple chains + }); + }); + + describe('Chain Assets Data Validation', () => { + it('should have valid chain asset structures', () => { + // Sample-based testing - test first 5 chains + chainKeys.slice(0, 5).forEach((chainName) => { + expectNonEmptyString(chainName); + const chainData = chainAssetsResponse.data[chainName]; + expect(typeof chainData).toBe('object'); + expect(chainData).not.toBeNull(); + + // Validate each section in the chain data + Object.entries(chainData).forEach(([section, sectionData]) => { + expectNonEmptyString(section); + expect(sectionData).toHaveProperty('total'); + expect(sectionData).toHaveProperty('breakdown'); + expect(typeof sectionData.total).toBe('string'); + expect(typeof sectionData.breakdown).toBe('object'); + }); + }); + }); + + it('should have valid total values', () => { + // Sample-based testing - test first 5 chains + chainKeys.slice(0, 5).forEach((chainName) => { + const chainData = chainAssetsResponse.data[chainName]; + Object.values(chainData).forEach((sectionData) => { + const total = parseFloat(sectionData.total); + expect(isNaN(total)).toBe(false); + expect(total).toBeGreaterThanOrEqual(0); + }); + }); + }); + + it('should have valid breakdown structures', () => { + // Sample-based testing - test first 3 chains for detailed breakdown + chainKeys.slice(0, 3).forEach((chainName) => { + const chainData = chainAssetsResponse.data[chainName]; + Object.values(chainData).forEach((sectionData) => { + Object.entries(sectionData.breakdown).forEach(([token, amount]) => { + expectNonEmptyString(token); + expectNonEmptyString(amount); + const amountNum = parseFloat(amount); + expect(isNaN(amountNum)).toBe(false); + expect(amountNum).toBeGreaterThanOrEqual(0); + }); + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle chains with empty or valid breakdowns', () => { + // Verify all chains have proper breakdown structure + chainKeys.forEach((chainName) => { + const chainData = chainAssetsResponse.data[chainName]; + Object.values(chainData).forEach((sectionData) => { + expect(typeof sectionData.breakdown).toBe('object'); + expect(sectionData.breakdown).not.toBeNull(); + }); + }); + }); + + it('should have reasonable timestamp', () => { + const timestamp = chainAssetsResponse.data.timestamp; + expect(timestamp).toBeGreaterThan(1262304000); // After Jan 1, 2010 + expect(timestamp).toBeLessThan(Date.now() / 1000 + 86400); // Not more than 1 day in future + }); + }); +}); + diff --git a/defi/api-tests/src/tvl/chainsV2.test.ts b/defi/api-tests/src/tvl/chainsV2.test.ts new file mode 100644 index 0000000000..c07fc407fe --- /dev/null +++ b/defi/api-tests/src/tvl/chainsV2.test.ts @@ -0,0 +1,144 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { ChainsV2, isChainsV2, } from './types'; +import { chainsV2ArraySchema } from './schemas'; +import { + expectSuccessfulResponse, + expectArrayResponse, + expectNonEmptyArray, + expectValidNumber, + expectNonNegativeNumber, + expectNonEmptyString, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.TVL.BASE_URL); +const TVL_ENDPOINTS = endpoints.TVL; + +describe('TVL API - Chains V2', () => { + let chainsResponse: ApiResponse; + + beforeAll(async () => { + chainsResponse = await apiClient.get(TVL_ENDPOINTS.CHAINS_V2); + }); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(chainsResponse); + expectArrayResponse(chainsResponse); + expectNonEmptyArray(chainsResponse.data); + expect(isChainsV2(chainsResponse.data)).toBe(true); + expect(chainsResponse.data.length).toBeGreaterThan(10); // Should have multiple chains + }); + + it('should validate against Zod schema', () => { + const result = validate(chainsResponse.data, chainsV2ArraySchema, 'ChainsV2'); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors:', result.errors); + } + }); + + it('should have required fields in all chains', () => { + const requiredFields = ['name', 'tvl']; + // Sample-based testing - validate first 10 chains + chainsResponse.data.slice(0, 10).forEach((chain) => { + requiredFields.forEach((field) => { + expect(chain).toHaveProperty(field); + }); + }); + }); + + it('should have unique chain names', () => { + const chainNames = chainsResponse.data.map((c) => c.name); + expect(new Set(chainNames).size).toBe(chainNames.length); + }); + }); + + describe('Chain Data Validation', () => { + it('should have valid chain properties', () => { + // Sample-based testing - validate first 10 chains + chainsResponse.data.slice(0, 10).forEach((chain) => { + // Required fields + expectNonEmptyString(chain.name); + expectValidNumber(chain.tvl); + expectNonNegativeNumber(chain.tvl); + expect(chain.tvl).toBeLessThan(10_000_000_000_000); // Reasonable max TVL + + // Optional fields - validate if present + if (chain.gecko_id !== null) { + expectNonEmptyString(chain.gecko_id); + } + + if (chain.tokenSymbol !== null) { + expectNonEmptyString(chain.tokenSymbol); + } + + if (chain.cmcId !== null) { + expectNonEmptyString(chain.cmcId); + } + + if (chain.chainId !== undefined && chain.chainId !== null) { + expectValidNumber(chain.chainId); + expect(chain.chainId).toBeGreaterThan(0); + } + }); + }); + + it('should have valid TVL values', () => { + const chainsWithTvl = chainsResponse.data.filter((c) => c.tvl > 0); + expect(chainsWithTvl.length).toBeGreaterThan(0); + + // Sample-based testing - validate first 10 chains with TVL + chainsWithTvl.slice(0, 10).forEach((chain) => { + expectValidNumber(chain.tvl); + expectNonNegativeNumber(chain.tvl); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle chains with zero TVL', () => { + const zeroTvlChains = chainsResponse.data.filter((c) => c.tvl === 0); + + // Verify zero TVL chains still have valid structure + zeroTvlChains.forEach((chain) => { + expect(chain.tvl).toBe(0); + expectNonEmptyString(chain.name); + }); + }); + + it('should handle chains with null optional fields', () => { + const chainsWithNulls = chainsResponse.data.filter( + (c) => c.gecko_id === null || c.tokenSymbol === null || c.cmcId === null + ); + + // Verify chains with null optional fields still have valid required fields + if (chainsWithNulls.length > 0) { + // Sample-based testing - validate first 5 chains with nulls + chainsWithNulls.slice(0, 5).forEach((chain) => { + expectNonEmptyString(chain.name); + expectValidNumber(chain.tvl); + expectNonNegativeNumber(chain.tvl); + }); + } + }); + + it('should include Ethereum chain with correct metadata', () => { + const ethereum = chainsResponse.data.find((c) => c.name === 'Ethereum'); + + expect(ethereum).toBeDefined(); + expect(ethereum!.gecko_id).toBe('ethereum'); + expect(ethereum!.tokenSymbol).toBe('ETH'); + expect(ethereum!.cmcId).toBe('1027'); + expect(ethereum!.name).toBe('Ethereum'); + expect(ethereum!.chainId).toBe(1); + + // TVL should be non-zero for Ethereum + expectValidNumber(ethereum!.tvl); + expect(ethereum!.tvl).toBeGreaterThan(0); + }); + }); +}); + diff --git a/defi/api-tests/src/tvl/historicalChainTvl.test.ts b/defi/api-tests/src/tvl/historicalChainTvl.test.ts new file mode 100644 index 0000000000..407a6746af --- /dev/null +++ b/defi/api-tests/src/tvl/historicalChainTvl.test.ts @@ -0,0 +1,199 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { HistoricalChainTvl, isHistoricalChainTvl } from './types'; +import { historicalChainTvlArraySchema } from './schemas'; +import { + expectSuccessfulResponse, + expectArrayResponse, + expectNonEmptyArray, + expectValidNumber, + expectNonNegativeNumber, + expectValidTimestamp, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.TVL.BASE_URL); +const TVL_ENDPOINTS = endpoints.TVL; + +describe('TVL API - Historical Chain TVL (All Chains)', () => { + const HISTORICAL_CHAIN_TVL_ENDPOINT = TVL_ENDPOINTS.HISTORICAL_CHAIN_TVL; + let allChainsResponse: ApiResponse; + + beforeAll(async () => { + allChainsResponse = await apiClient.get(HISTORICAL_CHAIN_TVL_ENDPOINT); + }); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(allChainsResponse); + expectArrayResponse(allChainsResponse); + expectNonEmptyArray(allChainsResponse.data); + expect(isHistoricalChainTvl(allChainsResponse.data)).toBe(true); + expect(allChainsResponse.data.length).toBeGreaterThan(100); // Should have historical data + }); + + it('should validate against Zod schema', () => { + const result = validate(allChainsResponse.data, historicalChainTvlArraySchema, 'HistoricalChainTvl'); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors:', result.errors); + } + }); + }); + + describe('Data Point Validation', () => { + it('should have valid data points structure', () => { + // Sample-based testing - validate first 10 points + allChainsResponse.data.slice(0, 10).forEach((point) => { + expectValidTimestamp(point.date); + expectValidNumber(point.tvl); + expectNonNegativeNumber(point.tvl); + }); + }); + + it('should have data points in chronological order', () => { + if (allChainsResponse.data.length > 1) { + const dates = allChainsResponse.data.map((p) => p.date); + const sortedDates = [...dates].sort((a, b) => a - b); + expect(dates).toEqual(sortedDates); + } + }); + + it('should have reasonable TVL values', () => { + // Sample-based testing - validate first 10 points + allChainsResponse.data.slice(0, 10).forEach((point) => { + expect(point.tvl).toBeGreaterThanOrEqual(0); + expect(point.tvl).toBeLessThan(10_000_000_000_000); // Max reasonable TVL + }); + }); + }); + + describe('Edge Cases', () => { + it('should exclude liquid staking and double counted TVL', () => { + if (allChainsResponse.data.length > 0) { + const latestPoint = allChainsResponse.data[allChainsResponse.data.length - 1]; + expectValidNumber(latestPoint.tvl); + expectNonNegativeNumber(latestPoint.tvl); + } + }); + }); +}); + +describe('TVL API - Historical Chain TVL (Specific Chain)', () => { + // Configure test chains - keep just one for speed, add more for thoroughness + const testChains = ['ethereum']; + // const testChains = ['ethereum', 'bsc', 'polygon', 'arbitrum', 'avalanche']; + const chainResponses: Record> = {}; + + beforeAll(async () => { + // Fetch all test chains in parallel once + await Promise.all( + testChains.map(async (chain) => { + chainResponses[chain] = await apiClient.get( + TVL_ENDPOINTS.HISTORICAL_CHAIN_TVL_BY_CHAIN(chain) + ); + }) + ); + }, 60000); + + describe('Basic Response Validation', () => { + testChains.forEach((chain) => { + describe(`Chain: ${chain}`, () => { + it('should return successful response with valid structure', () => { + const response = chainResponses[chain]; + expectSuccessfulResponse(response); + expectArrayResponse(response); + expectNonEmptyArray(response.data); + expect(isHistoricalChainTvl(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const response = chainResponses[chain]; + const result = validate(response.data, historicalChainTvlArraySchema, `HistoricalChainTvl-${chain}`); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors:', result.errors); + } + }); + }); + }); + }); + + describe('Data Point Validation', () => { + testChains.forEach((chain) => { + describe(`Chain: ${chain}`, () => { + it('should have valid data points structure', () => { + const response = chainResponses[chain]; + expect(response.data.length).toBeGreaterThan(0); + + // Sample-based testing - validate first 10 points + response.data.slice(0, 10).forEach((point) => { + expectValidTimestamp(point.date); + expectValidNumber(point.tvl); + expectNonNegativeNumber(point.tvl); + }); + }); + + it('should have data points in chronological order', () => { + const response = chainResponses[chain]; + if (response.data.length > 1) { + const dates = response.data.map((p) => p.date); + const sortedDates = [...dates].sort((a, b) => a - b); + expect(dates).toEqual(sortedDates); + } + }); + + it('should have reasonable TVL values', () => { + const response = chainResponses[chain]; + // Sample-based testing - validate first 10 points + response.data.slice(0, 10).forEach((point) => { + expect(point.tvl).toBeGreaterThanOrEqual(0); + expect(point.tvl).toBeLessThan(10_000_000_000_000); // Max reasonable TVL + }); + }); + + it('should have consistent date range', () => { + const response = chainResponses[chain]; + if (response.data.length > 1) { + const firstDate = response.data[0].date; + const lastDate = response.data[response.data.length - 1].date; + expect(lastDate).toBeGreaterThan(firstDate); + expect(firstDate).toBeGreaterThan(1262304000); // After Jan 1, 2010 + expect(lastDate).toBeLessThan(Date.now() / 1000); // Not in future + } + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent chain gracefully', async () => { + const response = await apiClient.get( + TVL_ENDPOINTS.HISTORICAL_CHAIN_TVL_BY_CHAIN('non-existent-chain-xyz-123') + ); + + // API may return 200 with empty array, error message, or 4xx status + expect(response.status).toBeGreaterThanOrEqual(200); + if (response.status === 200) { + // If 200, data should be array (possibly empty) or error object + expect(response.data).toBeDefined(); + } else { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }, 60000); + + it('should handle empty array for new chains', () => { + // Use first test chain + const response = chainResponses[testChains[0]]; + + if (response.data.length === 0) { + expect(Array.isArray(response.data)).toBe(true); + } else { + // Most chains should have data + expect(response.data.length).toBeGreaterThan(0); + } + }); + }); +}); + diff --git a/defi/api-tests/src/tvl/inflows.test.ts b/defi/api-tests/src/tvl/inflows.test.ts new file mode 100644 index 0000000000..fb3c499b39 --- /dev/null +++ b/defi/api-tests/src/tvl/inflows.test.ts @@ -0,0 +1,217 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { Inflows, isInflows } from './types'; +import { inflowsSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectObjectResponse, + expectValidNumber, + expectNonNegativeNumber, + expectNonEmptyString, + expectValidTimestamp, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.TVL.BASE_URL); +const TVL_ENDPOINTS = endpoints.TVL; + +describe('TVL API - Inflows', () => { + // Configure test protocols - keep just one for speed, add more for thoroughness + const testProtocols = ['uniswap']; + // const testProtocols = ['uniswap', 'aave', 'curve']; + const testTimestamp = Math.floor(Date.now() / 1000) - 86400 * 7; // 7 days ago + const inflowsResponses: Record> = {}; + + beforeAll(async () => { + // Fetch all test protocols in parallel once + await Promise.all( + testProtocols.map(async (slug) => { + inflowsResponses[slug] = await apiClient.get( + TVL_ENDPOINTS.INFLOWS(slug, testTimestamp) + ); + }) + ); + }, 60000); + + describe('Basic Response Validation', () => { + testProtocols.forEach((protocolSlug) => { + describe(`Protocol: ${protocolSlug}`, () => { + it('should return successful response or 400 for unsupported protocols', () => { + const response = inflowsResponses[protocolSlug]; + + // Some protocols may not have inflows data + if (response.status === 400) { + expect(response.status).toBe(400); + return; + } + + expectSuccessfulResponse(response); + expectObjectResponse(response); + expect(isInflows(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const response = inflowsResponses[protocolSlug]; + if (response.status === 400) return; // Skip for unsupported protocols + + const result = validate(response.data, inflowsSchema, `Inflows-${protocolSlug}`); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors:', result.errors); + } + }); + + it('should have required fields', () => { + const response = inflowsResponses[protocolSlug]; + if (response.status === 400) return; // Skip for unsupported protocols + + const requiredFields = ['outflows', 'oldTokens', 'currentTokens']; + requiredFields.forEach((field) => { + expect(response.data).toHaveProperty(field); + }); + }); + + it('should have valid outflows value', () => { + const response = inflowsResponses[protocolSlug]; + if (response.status === 400) return; // Skip for unsupported protocols + + expectValidNumber(response.data.outflows); + expect(response.data.outflows).toBeLessThan(10_000_000_000_000); // Max reasonable value + expect(response.data.outflows).toBeGreaterThan(-10_000_000_000_000); // Can be negative + }); + + it('should have valid token data structures', () => { + const response = inflowsResponses[protocolSlug]; + if (response.status === 400) return; // Skip for unsupported protocols + + expect(response.data.oldTokens).toHaveProperty('date'); + expect(response.data.oldTokens).toHaveProperty('tvl'); + expect(response.data.currentTokens).toHaveProperty('date'); + expect(response.data.currentTokens).toHaveProperty('tvl'); + + expect(typeof response.data.oldTokens.date).toBe('string'); + expect(typeof response.data.oldTokens.tvl).toBe('object'); + expect(typeof response.data.currentTokens.date).toBe('string'); + expect(typeof response.data.currentTokens.tvl).toBe('object'); + }); + }); + }); + }); + + describe('Token Data Validation', () => { + testProtocols.forEach((protocolSlug) => { + describe(`Protocol: ${protocolSlug}`, () => { + it('should have valid date strings', () => { + const response = inflowsResponses[protocolSlug]; + if (response.status === 400) return; // Skip for unsupported protocols + + const oldDate = parseInt(response.data.oldTokens.date); + const currentDate = parseInt(response.data.currentTokens.date); + + expect(!isNaN(oldDate)).toBe(true); + expect(!isNaN(currentDate)).toBe(true); + expectValidTimestamp(oldDate); + expectValidTimestamp(currentDate); + expect(currentDate).toBeGreaterThanOrEqual(oldDate); + }); + + it('should have valid token amounts in oldTokens', () => { + const response = inflowsResponses[protocolSlug]; + if (response.status === 400) return; // Skip for unsupported protocols + + Object.entries(response.data.oldTokens.tvl).forEach(([token, amount]) => { + expectNonEmptyString(token); + expectValidNumber(amount); + expectNonNegativeNumber(amount); + }); + }); + + it('should have valid token amounts in currentTokens', () => { + const response = inflowsResponses[protocolSlug]; + if (response.status === 400) return; // Skip for unsupported protocols + + Object.entries(response.data.currentTokens.tvl).forEach(([token, amount]) => { + expectNonEmptyString(token); + expectValidNumber(amount); + expectNonNegativeNumber(amount); + }); + }); + + it('should have consistent token sets', () => { + const response = inflowsResponses[protocolSlug]; + if (response.status === 400) return; // Skip for unsupported protocols + + const oldTokenKeys = Object.keys(response.data.oldTokens.tvl); + const currentTokenKeys = Object.keys(response.data.currentTokens.tvl); + const allTokens = new Set([...oldTokenKeys, ...currentTokenKeys]); + + expect(allTokens.size).toBeGreaterThan(0); + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent protocol gracefully', async () => { + const response = await apiClient.get( + TVL_ENDPOINTS.INFLOWS('non-existent-protocol-xyz-123', testTimestamp) + ); + + expect(response.status).toBeGreaterThanOrEqual(400); + }); + + it('should handle invalid timestamp gracefully', async () => { + const response = await apiClient.get( + TVL_ENDPOINTS.INFLOWS('ethereum', 0) + ); + + if (response.status === 200) { + expect(isInflows(response.data)).toBe(true); + } else { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }, 60000); + + it('should handle future timestamp', async () => { + const futureTimestamp = Math.floor(Date.now() / 1000) + 3600 * 24 * 365; + const response = await apiClient.get( + TVL_ENDPOINTS.INFLOWS('ethereum', futureTimestamp) + ); + + if (response.status === 200) { + expect(isInflows(response.data)).toBe(true); + } else { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }, 60000); + + it('should handle empty token sets', () => { + // Use first test protocol + const response = inflowsResponses[testProtocols[0]]; + if (response.status === 400) return; // Skip for unsupported protocols + + const oldTokenCount = Object.keys(response.data.oldTokens.tvl).length; + const currentTokenCount = Object.keys(response.data.currentTokens.tvl).length; + + if (oldTokenCount === 0 || currentTokenCount === 0) { + expect(typeof response.data.outflows).toBe('number'); + } else { + // Most protocols should have tokens + expect(oldTokenCount).toBeGreaterThan(0); + expect(currentTokenCount).toBeGreaterThan(0); + } + }); + + it('should have reasonable outflows value', () => { + // Use first test protocol + const response = inflowsResponses[testProtocols[0]]; + if (response.status === 400) return; // Skip for unsupported protocols + + expectValidNumber(response.data.outflows); + expect(response.data.outflows).toBeLessThan(10_000_000_000_000); + expect(response.data.outflows).toBeGreaterThan(-10_000_000_000_000); + }); + }); +}); + diff --git a/defi/api-tests/src/tvl/protocols.test.ts b/defi/api-tests/src/tvl/protocols.test.ts new file mode 100644 index 0000000000..ad9ba25b07 --- /dev/null +++ b/defi/api-tests/src/tvl/protocols.test.ts @@ -0,0 +1,597 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { Protocol, ProtocolDetails, isProtocolArray, isProtocolDetails } from './types'; +import { protocolSchema, protocolDetailsSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectArrayResponse, + expectObjectResponse, + expectNonEmptyArray, + expectValidNumber, + expectNonNegativeNumber, + expectNonEmptyString, + expectValidTimestamp, +} from '../../utils/testHelpers'; +import { validate, isValid } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; +import { z } from 'zod'; + +const apiClient = createApiClient(endpoints.TVL.BASE_URL); +const TVL_ENDPOINTS = endpoints.TVL; + +describe('TVL API - Protocols', () => { + const PROTOCOLS_ENDPOINT = TVL_ENDPOINTS.PROTOCOLS; + let protocolsResponse: ApiResponse; + + beforeAll(async () => { + protocolsResponse = await apiClient.get(PROTOCOLS_ENDPOINT); + }); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(protocolsResponse); + expectArrayResponse(protocolsResponse); + expectNonEmptyArray(protocolsResponse.data); + expect(isProtocolArray(protocolsResponse.data)).toBe(true); + expect(protocolsResponse.data.length).toBeGreaterThan(100); + }); + + it('should validate all protocols against Zod schema', () => { + const errors: string[] = []; + const invalidProtocols: any[] = []; + + protocolsResponse.data.forEach((protocol, idx) => { + const result = validate(protocol, protocolSchema, `Protocol[${idx}]`); + if (!result.success) { + invalidProtocols.push(protocol); + errors.push(...result.errors); + } + }); + + if (invalidProtocols.length > 0) { + console.error(`\nFound ${invalidProtocols.length} invalid protocols:`); + console.error('Sample invalid protocols:', invalidProtocols.slice(0, 3).map(p => ({ + name: p.name || 'unknown', + slug: p.slug || 'unknown', + id: p.id || 'missing' + }))); + console.error(`\nFirst 10 validation errors:\n${errors.slice(0, 10).join('\n')}`); + } + + expect(invalidProtocols.length).toBe(0); + }); + + it('should have unique identifiers', () => { + const ids = protocolsResponse.data.map((p) => p.id); + const slugs = protocolsResponse.data.map((p) => p.slug); + const slugSet = new Set(); + const idSet = new Set(); + + protocolsResponse.data.forEach((protocol: any) => { + if (slugSet.has(protocol.slug)) { + throw new Error(`Duplicate slug found: ${protocol.slug} ${protocol.name}`); + } + slugSet.add(protocol.slug); + + if (idSet.has(protocol.id)) { + throw new Error(`Duplicate id found: ${protocol.id} ${protocol.name}`); + } + idSet.add(protocol.id); + }); + expect(new Set(ids).size).toBe(ids.length); + expect(new Set(slugs).size).toBe(slugs.length); + }); + }); + + describe('TVL Data Validation', () => { + it('should have valid TVL values', () => { + const protocolsWithTvl = protocolsResponse.data.filter((p) => p.tvl !== null && p.tvl >= 0); + expect(protocolsWithTvl.length).toBeGreaterThan(0); + + protocolsWithTvl.forEach((protocol) => { + expectValidNumber(protocol.tvl!); + expectNonNegativeNumber(protocol.tvl!); + expect(protocol.tvl!).toBeLessThan(10_000_000_000_000); + }); + }); + + + it('should have valid chain TVLs and borrowed amounts', () => { + const protocolsWithChainTvls = protocolsResponse.data.filter( + (p) => p.chainTvls && Object.keys(p.chainTvls).length > 0 + ); + expect(protocolsWithChainTvls.length).toBeGreaterThan(0); + + protocolsWithChainTvls.slice(0, 10).forEach((protocol) => { + Object.entries(protocol.chainTvls).forEach(([chain, tvl]) => { + expectNonEmptyString(chain); + expectValidNumber(tvl); + expectNonNegativeNumber(tvl); + }); + + if ('borrowed' in protocol.chainTvls) { + expectValidNumber(protocol.chainTvls.borrowed!); + expectNonNegativeNumber(protocol.chainTvls.borrowed!); + } + }); + }); + }); + + describe('Protocol Metadata', () => { + it('should have valid categories and chains', () => { + const protocolsWithCategory = protocolsResponse.data.filter((p) => p.category); + expect(protocolsWithCategory.length).toBeGreaterThan(0); + + const categories = new Set(protocolsResponse.data.map((p) => p.category).filter(Boolean)); + expect(categories.size).toBeGreaterThan(5); + + const allChains = new Set(); + protocolsResponse.data.forEach((p) => p.chains.forEach((c) => allChains.add(c))); + expect(allChains.size).toBeGreaterThan(10); + }); + + it('should have valid percentage changes', () => { + const protocolsWithChanges = protocolsResponse.data.filter( + (p) => p.change_1h !== null || p.change_1d !== null || p.change_7d !== null + ); + expect(protocolsWithChanges.length).toBeGreaterThan(0); + + protocolsWithChanges.slice(0, 10).forEach((protocol) => { + if (protocol.change_1h !== null) expectValidNumber(protocol.change_1h); + if (protocol.change_1d !== null) expectValidNumber(protocol.change_1d); + if (protocol.change_7d !== null) expectValidNumber(protocol.change_7d); + }); + }); + + it('should have valid URLs when present', () => { + const protocolsWithUrl = protocolsResponse.data.filter((p) => p.url || p.logo); + expect(protocolsWithUrl.length).toBeGreaterThan(0); + + const urlSchema = z.string().regex(/^https?:\/\/.+/); + + protocolsWithUrl.slice(0, 10).forEach((protocol) => { + if (protocol.url) { + expect(isValid(protocol.url, urlSchema)).toBe(true); + } + if (protocol.logo) { + expect(isValid(protocol.logo, urlSchema)).toBe(true); + } + }); + }); + + it('should have valid timestamps and parent relationships', () => { + const protocolsWithListedAt = protocolsResponse.data.filter((p) => p.listedAt); + expect(protocolsWithListedAt.length).toBeGreaterThan(0); + + protocolsWithListedAt.slice(0, 10).forEach((protocol) => { + expectValidNumber(protocol.listedAt!); + expect(protocol.listedAt!).toBeGreaterThan(1500000000); + expect(protocol.listedAt!).toBeLessThan(Date.now() / 1000); + }); + + const protocolsWithParent = protocolsResponse.data.filter((p) => p.parentProtocolSlug); + if (protocolsWithParent.length > 0) { + protocolsWithParent.slice(0, 5).forEach((protocol) => { + expectNonEmptyString(protocol.parentProtocolSlug!); + expect(protocol.parentProtocolSlug).not.toBe(protocol.slug); + }); + } + }); + + it('should have valid oracles and methodology when present', () => { + const protocolsWithOracles = protocolsResponse.data.filter((p) => p.oracles && p.oracles.length > 0); + if (protocolsWithOracles.length > 0) { + protocolsWithOracles.slice(0, 5).forEach((protocol) => { + expect(Array.isArray(protocol.oracles)).toBe(true); + protocol.oracles!.forEach((oracle) => expectNonEmptyString(oracle)); + }); + } + + const protocolsWithMethodology = protocolsResponse.data.filter((p) => p.methodology); + if (protocolsWithMethodology.length > 0) { + protocolsWithMethodology.slice(0, 3).forEach((protocol) => { + expectNonEmptyString(protocol.methodology!); + }); + } + }); + }); + + describe('Edge Cases', () => { + it('should handle protocols with different chain counts', () => { + const emptyChains = protocolsResponse.data.filter((p) => p.chains.length === 0); + const singleChain = protocolsResponse.data.filter((p) => p.chains.length === 1); + const multiChain = protocolsResponse.data.filter((p) => p.chains.length > 5); + + expect(singleChain.length).toBeGreaterThan(0); + expect(multiChain.length).toBeGreaterThan(0); + + multiChain.slice(0, 3).forEach((protocol) => { + protocol.chains.forEach((chain) => expectNonEmptyString(chain)); + }); + }); + + it('should handle protocols with null or zero TVL', () => { + const nullTvl = protocolsResponse.data.filter((p) => p.tvl === null); + const zeroTvl = protocolsResponse.data.filter((p) => p.tvl === 0); + + nullTvl.forEach((protocol) => expect(protocol.tvl).toBeNull()); + zeroTvl.forEach((protocol) => expect(protocol.tvl).toBe(0)); + }); + + it('should handle optional fields correctly', () => { + const withStaking = protocolsResponse.data.filter((p) => p.staking !== undefined); + const withMcap = protocolsResponse.data.filter((p) => p.mcap !== null); + const withPool2 = protocolsResponse.data.filter((p) => p.pool2 !== undefined); + + if (withStaking.length > 0) { + withStaking.slice(0, 3).forEach((protocol) => { + expectValidNumber(protocol.staking!); + expectNonNegativeNumber(protocol.staking!); + }); + } + + if (withMcap.length > 0) { + withMcap.slice(0, 3).forEach((protocol) => { + expectValidNumber(protocol.mcap!); + expectNonNegativeNumber(protocol.mcap!); + }); + } + + if (withPool2.length > 0) { + withPool2.slice(0, 3).forEach((protocol) => { + expectValidNumber(protocol.pool2!); + expectNonNegativeNumber(protocol.pool2!); + }); + } + }); + + it('should handle protocol flags correctly', () => { + const withFlags = protocolsResponse.data.filter( + (p) => p.rugged || p.misrepresentedTokens || p.wrongLiquidity || p.openSource !== undefined + ); + + if (withFlags.length > 0) { + withFlags.forEach((protocol) => { + if (protocol.rugged !== undefined) expect(typeof protocol.rugged).toBe('boolean'); + if (protocol.misrepresentedTokens !== undefined) expect(typeof protocol.misrepresentedTokens).toBe('boolean'); + if (protocol.wrongLiquidity !== undefined) expect(typeof protocol.wrongLiquidity).toBe('boolean'); + if (protocol.openSource !== undefined) expect(typeof protocol.openSource).toBe('boolean'); + }); + } + }); + }); +}); + +describe('TVL API - Protocol Details', () => { + // Configure test protocols - add more to test multiple protocols, or keep just one for speed + const testProtocols = ['aave-v3']; + const protocolResponses: Record> = {}; + + beforeAll(async () => { + await Promise.all( + testProtocols.map(async (slug) => { + try { + protocolResponses[slug] = await apiClient.get(TVL_ENDPOINTS.PROTOCOL(slug)); + } catch (error: any) { + console.error(`Failed to fetch protocol ${slug}:`, error.message, error.details); + throw error; + } + }) + ); + }, 60000); + + describe('Basic Response Validation', () => { + testProtocols.forEach((slug) => { + describe(`Protocol: ${slug}`, () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(protocolResponses[slug]); + expectObjectResponse(protocolResponses[slug]); + expect(isProtocolDetails(protocolResponses[slug].data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate(protocolResponses[slug].data, protocolDetailsSchema, 'ProtocolDetails'); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors:', result.errors); + } + }); + + it('should have required fields', () => { + const requiredFields = ['id', 'name', 'chains', 'chainTvls']; + requiredFields.forEach((field) => { + expect(protocolResponses[slug].data).toHaveProperty(field); + }); + }); + + it('should have valid identifiers', () => { + expectNonEmptyString(protocolResponses[slug].data.id); + expectNonEmptyString(protocolResponses[slug].data.name); + if (protocolResponses[slug].data.slug) { + expectNonEmptyString(protocolResponses[slug].data.slug); + } + }); + + it('should have valid chains array', () => { + expect(Array.isArray(protocolResponses[slug].data.chains)).toBe(true); + protocolResponses[slug].data.chains.forEach((chain) => { + expectNonEmptyString(chain); + }); + }); + }); + }); + }); + + describe('Historical TVL Data', () => { + testProtocols.forEach((slug) => { + describe(`Protocol: ${slug}`, () => { + it('should have valid TVL array structure', () => { + const response = protocolResponses[slug]; + if (response.data.tvl !== null) { + expect(Array.isArray(response.data.tvl)).toBe(true); + + if (response.data.tvl.length > 0) { + response.data.tvl.slice(0, 10).forEach((point) => { + expectValidTimestamp(point.date); + expectValidNumber(point.totalLiquidityUSD); + expectNonNegativeNumber(point.totalLiquidityUSD); + }); + } + } + }); + + it('should have TVL data points in chronological order', () => { + const response = protocolResponses[slug]; + if (response.data.tvl && response.data.tvl.length > 1) { + const dates = response.data.tvl.map((p) => p.date); + const sortedDates = [...dates].sort((a, b) => a - b); + expect(dates).toEqual(sortedDates); + } + }); + }); + }); + }); + + describe('Chain TVL Breakdowns', () => { + testProtocols.forEach((slug) => { + describe(`Protocol: ${slug}`, () => { + it('should have valid chainTvls structure', () => { + const response = protocolResponses[slug]; + expect(typeof response.data.chainTvls).toBe('object'); + expect(response.data.chainTvls).not.toBeNull(); + + const chainKeys = Object.keys(response.data.chainTvls); + expect(chainKeys.length).toBeGreaterThan(0); + + chainKeys.slice(0, 5).forEach((chain) => { + expectNonEmptyString(chain); + const chainData = response.data.chainTvls[chain]; + expect(chainData).toHaveProperty('tvl'); + expect(Array.isArray(chainData.tvl)).toBe(true); + + if (chainData.tvl.length > 0) { + chainData.tvl.slice(0, 5).forEach((point) => { + expectValidTimestamp(point.date); + expectValidNumber(point.totalLiquidityUSD); + expectNonNegativeNumber(point.totalLiquidityUSD); + }); + } + }); + }); + + it('should have chainTvls matching protocol chains', () => { + const response = protocolResponses[slug]; + const chainTvlKeys = Object.keys(response.data.chainTvls); + const protocolChains = response.data.chains; + + if (protocolChains.length > 0) { + chainTvlKeys.forEach((chain) => { + if (!chain.includes('-') && !chain.includes('borrowed') && !chain.includes('staking') && !chain.includes('pool2')) { + expect(protocolChains).toContain(chain); + } + }); + } + }); + + it('should have valid currentChainTvls if present', () => { + const response = protocolResponses[slug]; + if (response.data.currentChainTvls) { + Object.entries(response.data.currentChainTvls).forEach(([chain, tvl]) => { + expectNonEmptyString(chain); + expectValidNumber(tvl); + expectNonNegativeNumber(tvl); + }); + } + }); + }); + }); + }); + + describe('Token Breakdowns', () => { + testProtocols.forEach((slug) => { + describe(`Protocol: ${slug}`, () => { + it('should have valid tokensInUsd structure if present', () => { + const response = protocolResponses[slug]; + if (response.data.tokensInUsd && response.data.tokensInUsd.length > 0) { + expect(Array.isArray(response.data.tokensInUsd)).toBe(true); + + response.data.tokensInUsd.slice(0, 5).forEach((point) => { + expectValidTimestamp(point.date); + expect(typeof point.tokens).toBe('object'); + expect(point.tokens).not.toBeNull(); + + Object.entries(point.tokens).forEach(([token, amount]) => { + expectNonEmptyString(token); + expectValidNumber(amount); + expectNonNegativeNumber(amount); + }); + }); + } + }); + + it('should have valid tokens structure if present', () => { + const response = protocolResponses[slug]; + if (response.data.tokens && response.data.tokens.length > 0) { + expect(Array.isArray(response.data.tokens)).toBe(true); + + response.data.tokens.slice(0, 5).forEach((point) => { + expectValidTimestamp(point.date); + expect(typeof point.tokens).toBe('object'); + expect(point.tokens).not.toBeNull(); + + Object.entries(point.tokens).forEach(([token, amount]) => { + expectNonEmptyString(token); + expectValidNumber(amount); + expectNonNegativeNumber(amount); + }); + }); + } + }); + + it('should have token breakdowns in chainTvls if present', () => { + const response = protocolResponses[slug]; + Object.entries(response.data.chainTvls).slice(0, 3).forEach(([, chainData]) => { + if (chainData.tokensInUsd && chainData.tokensInUsd !== null && chainData.tokensInUsd.length > 0) { + chainData.tokensInUsd.slice(0, 3).forEach((point) => { + expectValidTimestamp(point.date); + expect(typeof point.tokens).toBe('object'); + }); + } + + if (chainData.tokens && chainData.tokens !== null && chainData.tokens.length > 0) { + chainData.tokens.slice(0, 3).forEach((point) => { + expectValidTimestamp(point.date); + expect(typeof point.tokens).toBe('object'); + }); + } + }); + }); + }); + }); + }); + + describe('Metadata Fields', () => { + testProtocols.forEach((slug) => { + describe(`Protocol: ${slug}`, () => { + it('should have valid optional metadata fields', () => { + const response = protocolResponses[slug]; + if (response.data.category) { + expectNonEmptyString(response.data.category); + } + + if (response.data.url) { + expect(response.data.url).toMatch(/^https?:\/\//); + } + + if (response.data.logo) { + expect(response.data.logo).toMatch(/^https?:\/\//); + } + + if (response.data.description) { + expect(typeof response.data.description).toBe('string'); + } + + if (response.data.methodology) { + expectNonEmptyString(response.data.methodology); + } + }); + + it('should have valid raises array if present', () => { + const response = protocolResponses[slug]; + if (response.data.raises && response.data.raises.length > 0) { + expect(Array.isArray(response.data.raises)).toBe(true); + + response.data.raises.slice(0, 3).forEach((raise) => { + if (raise.date) expectNonEmptyString(raise.date); + if (raise.round) expectNonEmptyString(raise.round); + if (raise.amount !== undefined) { + expectValidNumber(raise.amount); + expectNonNegativeNumber(raise.amount); + } + if (raise.valuation !== undefined) { + expectValidNumber(raise.valuation); + expectNonNegativeNumber(raise.valuation); + } + }); + } + }); + + it('should have valid hallmarks if present', () => { + const response = protocolResponses[slug]; + if (response.data.hallmarks && response.data.hallmarks.length > 0) { + expect(Array.isArray(response.data.hallmarks)).toBe(true); + + response.data.hallmarks.slice(0, 5).forEach((hallmark) => { + if (Array.isArray(hallmark) && hallmark.length >= 2) { + expectValidTimestamp(hallmark[0]); + expect(typeof hallmark[1]).toBe('string'); + } + }); + } + }); + + it('should have valid token metrics if present', () => { + const response = protocolResponses[slug]; + if (response.data.tokenPrice !== null && response.data.tokenPrice !== undefined) { + expectValidNumber(response.data.tokenPrice); + expectNonNegativeNumber(response.data.tokenPrice); + } + + if (response.data.tokenMcap !== null && response.data.tokenMcap !== undefined) { + expectValidNumber(response.data.tokenMcap); + expectNonNegativeNumber(response.data.tokenMcap); + } + + if (response.data.tokenSupply !== null && response.data.tokenSupply !== undefined) { + expectValidNumber(response.data.tokenSupply); + expectNonNegativeNumber(response.data.tokenSupply); + } + + if (response.data.mcap !== null && response.data.mcap !== undefined) { + expectValidNumber(response.data.mcap); + expectNonNegativeNumber(response.data.mcap); + } + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent protocol gracefully', async () => { + const NONEXISTENT_ENDPOINT = TVL_ENDPOINTS.PROTOCOL('non-existent-protocol-xyz-123'); + const response = await apiClient.get(NONEXISTENT_ENDPOINT); + expect(response.status).toBeGreaterThanOrEqual(400); + }); + + testProtocols.forEach((slug) => { + describe(`Protocol: ${slug}`, () => { + it('should handle protocol with null TVL', () => { + const response = protocolResponses[slug]; + if (response.data.tvl === null) { + expect(response.data.tvl).toBeNull(); + } + }); + + it('should handle protocol with empty chainTvls', () => { + const response = protocolResponses[slug]; + expect(typeof response.data.chainTvls).toBe('object'); + }); + + it('should handle protocol with parent/child relationships', () => { + const response = protocolResponses[slug]; + + if (response.data.otherProtocols) { + expect(Array.isArray(response.data.otherProtocols)).toBe(true); + response.data.otherProtocols.forEach((name) => { + expectNonEmptyString(name); + }); + } + + if (response.data.parentProtocolSlug) { + expectNonEmptyString(response.data.parentProtocolSlug); + } + }); + }); + }); + }); +}); diff --git a/defi/api-tests/src/tvl/schemas.ts b/defi/api-tests/src/tvl/schemas.ts new file mode 100644 index 0000000000..c6d99501a8 --- /dev/null +++ b/defi/api-tests/src/tvl/schemas.ts @@ -0,0 +1,234 @@ +// Zod schemas for TVL API endpoints +// These define the runtime validation and TypeScript types +// Source reference: defi/src/protocols/types.ts + +import { z } from 'zod'; + +// ============================================================================ +// Base Schemas +// ============================================================================ + +export const chainTvlSchema = z.record(z.string(), z.number().finite()); + +export const tokenTvlSchema = z.record(z.string(), z.number()); + +// ============================================================================ +// Protocol Schema (from /protocols endpoint) +// ============================================================================ + +export const protocolSchema = z.object({ + // Required fields + id: z.string().min(1), + name: z.string().min(1), + symbol: z.string(), + chains: z.array(z.string()), + slug: z.string().min(1), + chainTvls: chainTvlSchema, + tvl: z.number().finite().nullable(), + + // Optional fields + address: z.string().nullable().optional(), + url: z.string().optional(), + description: z.string().nullable().optional(), + chain: z.string().optional(), + logo: z.string().nullable().optional(), + audits: z.string().nullable().optional(), + audit_note: z.string().nullable().optional(), + gecko_id: z.string().nullable().optional(), + cmcId: z.string().nullable().optional(), + mcap: z.number().finite().nullable().optional(), + category: z.string().optional(), + module: z.string().optional(), + twitter: z.string().nullable().optional(), + listedAt: z.number().optional(), + forkedFrom: z.array(z.string()).optional(), + oracles: z.array(z.string()).optional(), + audit_links: z.array(z.string()).optional(), + change_1h: z.number().nullable().optional(), + change_1d: z.number().nullable().optional(), + change_7d: z.number().nullable().optional(), + tokenBreakdowns: z.record(z.string(), tokenTvlSchema).optional(), + parentProtocolSlug: z.string().optional(), + staking: z.number().optional(), + pool2: z.number().optional(), + openSource: z.boolean().optional(), + wrongLiquidity: z.boolean().optional(), + misrepresentedTokens: z.boolean().optional(), + rugged: z.boolean().optional(), + dimensions: z.record(z.string(), z.any()).optional(), + methodology: z.string().optional(), + governanceID: z.array(z.string()).optional(), + github: z.array(z.string()).optional(), + oraclesBreakdown: z.array(z.object({ + name: z.string(), + type: z.string(), + proof: z.array(z.string()).optional(), + chains: z.array(z.object({ + chain: z.string(), + })).optional(), + })).optional(), + // Hallmarks can have various formats in the actual data + hallmarks: z.array(z.union([ + z.tuple([z.number(), z.string()]), + z.array(z.any()) + ])).optional(), +}); + +// ============================================================================ +// Protocol Details Schema (from /protocol/:slug endpoint) +// ============================================================================ + +export const historicalDataPointSchema = z.object({ + date: z.number(), + totalLiquidityUSD: z.number().finite().nonnegative(), +}); + +const chainTvlDetailSchema = z.object({ + tvl: z.array(historicalDataPointSchema), + tokensInUsd: z.array(z.object({ + date: z.number(), + tokens: z.record(z.string(), z.number()), + })).nullable().optional(), + tokens: z.array(z.object({ + date: z.number(), + tokens: z.record(z.string(), z.number()), + })).nullable().optional(), +}); + +export const protocolDetailsSchema = protocolSchema.extend({ + // Override tvl and chainTvls for details endpoint + tvl: z.array(historicalDataPointSchema).nullable(), + chainTvls: z.record(z.string(), chainTvlDetailSchema), + currentChainTvls: chainTvlSchema.optional(), + + // slug is not returned in protocol details endpoint + slug: z.string().optional(), + + // Additional fields in details + otherProtocols: z.array(z.string()).optional(), + tokensInUsd: z.array(z.object({ + date: z.number(), + tokens: z.record(z.string(), z.number()), + })).optional(), + tokens: z.array(z.object({ + date: z.number(), + tokens: z.record(z.string(), z.number()), + })).optional(), + raises: z.array(z.object({ + date: z.union([z.string(), z.number()]).optional(), + round: z.string().optional(), + amount: z.number().optional(), + valuation: z.number().nullable().optional(), + otherInvestors: z.array(z.string()).optional(), + })).optional(), + isParentProtocol: z.boolean().optional(), + metrics: z.record(z.string(), z.boolean()).optional(), + tokenPrice: z.number().nullable().optional(), + tokenMcap: z.number().nullable().optional(), + tokenSupply: z.number().nullable().optional(), +}); + +// ============================================================================ +// Chart Data Schemas +// ============================================================================ + +export const chartDataPointSchema = z.object({ + date: z.union([z.string(), z.number()]), + totalLiquidityUSD: z.number().finite().nonnegative(), +}); + +export const historicalTvlPointSchema = z.object({ + date: z.number(), + tvl: z.number(), +}); + +// ============================================================================ +// Historical Chain TVL Schema +// ============================================================================ + +export const historicalChainTvlPointSchema = z.object({ + date: z.number(), + tvl: z.number().finite().nonnegative(), +}); + +export const historicalChainTvlArraySchema = z.array(historicalChainTvlPointSchema); + +// ============================================================================ +// Chain Schema +// ============================================================================ + +export const chainSchema = z.object({ + gecko_id: z.string().nullable(), + tvl: z.number(), + tokenSymbol: z.string().nullable(), + cmcId: z.string().nullable(), + name: z.string(), + chainId: z.union([ + z.number(), + z.string().transform(Number), + z.null() + ]).optional(), +}); + +// ============================================================================ +// Protocol TVL Schema (from /tvl/{protocol} endpoint) +// ============================================================================ + +export const protocolTvlSchema = z.number().finite().nonnegative(); + +// ============================================================================ +// Chains V2 Schema (from /v2/chains endpoint) +// ============================================================================ + +export const chainsV2ArraySchema = z.array(chainSchema); + +// ============================================================================ +// Chain Assets Schema (from /chainAssets endpoint) +// ============================================================================ + +const chainAssetSectionSchema = z.object({ + total: z.string(), + breakdown: z.record(z.string(), z.string()), +}); + +const chainAssetDataSchema = z.record(z.string(), chainAssetSectionSchema); + +export const chainAssetsSchema = z.object({ + timestamp: z.number(), +}).catchall(chainAssetDataSchema); + +// ============================================================================ +// Token Protocols Schema (from /tokenProtocols/{symbol} endpoint) +// ============================================================================ + +export const tokenProtocolSchema = z.object({ + name: z.string().min(1), + category: z.string(), + amountUsd: z.record(z.string(), z.number().finite()), // Can be negative (debt protocols) + misrepresentedTokens: z.boolean(), +}); + +export const tokenProtocolsArraySchema = z.array(tokenProtocolSchema); + +// ============================================================================ +// Inflows Schema (from /inflows/{protocol}/{timestamp} endpoint) +// ============================================================================ + +export const tokenTvlDataSchema = z.object({ + date: z.string(), + tvl: z.record(z.string(), z.number().finite().nonnegative()), +}); + +export const inflowsSchema = z.object({ + outflows: z.number().finite(), + oldTokens: tokenTvlDataSchema, + currentTokens: tokenTvlDataSchema, +}); + +// ============================================================================ +// Array Schemas +// ============================================================================ + +export const protocolsArraySchema = z.array(protocolSchema); +export const chartDataArraySchema = z.array(chartDataPointSchema); + diff --git a/defi/api-tests/src/tvl/tokenProtocols.test.ts b/defi/api-tests/src/tvl/tokenProtocols.test.ts new file mode 100644 index 0000000000..e6e954d715 --- /dev/null +++ b/defi/api-tests/src/tvl/tokenProtocols.test.ts @@ -0,0 +1,184 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { TokenProtocols, isTokenProtocols } from './types'; +import { tokenProtocolsArraySchema } from './schemas'; +import { + expectSuccessfulResponse, + expectArrayResponse, + expectValidNumber, + expectNonNegativeNumber, + expectNonEmptyString, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.TVL.BASE_URL); +const TVL_ENDPOINTS = endpoints.TVL; + +describe('TVL API - Token Protocols', () => { + // Configure test symbols - keep just one for speed, add more for thoroughness + const testSymbols = ['USDC']; + // const testSymbols = ['USDC', 'USDT', 'ETH', 'BTC', 'DAI']; + const symbolResponses: Record> = {}; + + beforeAll(async () => { + // Fetch all test symbols in parallel once + await Promise.all( + testSymbols.map(async (symbol) => { + symbolResponses[symbol] = await apiClient.get( + TVL_ENDPOINTS.TOKEN_PROTOCOLS(symbol) + ); + }) + ); + }, 60000); + + describe('Basic Response Validation', () => { + testSymbols.forEach((symbol) => { + describe(`Symbol: ${symbol}`, () => { + it('should return successful response with valid structure', () => { + const response = symbolResponses[symbol]; + expectSuccessfulResponse(response); + expectArrayResponse(response); + expect(isTokenProtocols(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const response = symbolResponses[symbol]; + const result = validate(response.data, tokenProtocolsArraySchema, `TokenProtocols-${symbol}`); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors:', result.errors); + } + }); + + it('should have required fields in all protocols', () => { + const response = symbolResponses[symbol]; + if (response.data.length > 0) { + const requiredFields = ['name', 'category', 'amountUsd', 'misrepresentedTokens']; + // Sample-based testing - validate first 10 protocols + response.data.slice(0, 10).forEach((protocol) => { + requiredFields.forEach((field) => { + expect(protocol).toHaveProperty(field); + }); + }); + } + }); + + it('should have valid protocol properties', () => { + const response = symbolResponses[symbol]; + if (response.data.length > 0) { + // Sample-based testing - validate first 10 protocols + response.data.slice(0, 10).forEach((protocol) => { + expectNonEmptyString(protocol.name); + expect(typeof protocol.category).toBe('string'); + expect(typeof protocol.amountUsd).toBe('object'); + expect(protocol.amountUsd).not.toBeNull(); + expect(typeof protocol.misrepresentedTokens).toBe('boolean'); + }); + } + }); + }); + }); + }); + + describe('Token Amount Data Validation', () => { + testSymbols.forEach((symbol) => { + describe(`Symbol: ${symbol}`, () => { + it('should have valid amountUsd values', () => { + const response = symbolResponses[symbol]; + if (response.data.length > 0) { + // Sample-based testing - validate first 10 protocols + response.data.slice(0, 10).forEach((protocol) => { + Object.entries(protocol.amountUsd).forEach(([token, amount]) => { + expectNonEmptyString(token); + expectValidNumber(amount); + // Amount can be negative for debt protocols (VARIABLEDEBT*, etc.) + expect(Math.abs(amount)).toBeLessThan(10_000_000_000_000); // Max reasonable absolute amount + }); + }); + } + }); + + it('should have token symbols matching the query symbol', () => { + const response = symbolResponses[symbol]; + if (response.data.length > 0) { + // Sample-based testing - validate first 10 protocols + response.data.slice(0, 10).forEach((protocol) => { + const tokenKeys = Object.keys(protocol.amountUsd); + expect(tokenKeys.length).toBeGreaterThan(0); + // Token keys should contain the queried symbol + tokenKeys.forEach((token) => { + expect(token.toUpperCase()).toContain(symbol.toUpperCase()); + }); + }); + } + }); + + it('should have non-empty amountUsd objects', () => { + const response = symbolResponses[symbol]; + if (response.data.length > 0) { + response.data.forEach((protocol) => { + expect(Object.keys(protocol.amountUsd).length).toBeGreaterThan(0); + }); + } + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent token symbol gracefully', async () => { + const response = await apiClient.get( + TVL_ENDPOINTS.TOKEN_PROTOCOLS('NONEXISTENTTOKENXYZ123') + ); + + // API may return 200 with empty array or 4xx status + if (response.status === 200) { + expect(Array.isArray(response.data)).toBe(true); + expect(response.data.length).toBe(0); + } else { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }); + + it('should handle protocols with misrepresentedTokens flag', () => { + // Use first test symbol + const response = symbolResponses[testSymbols[0]]; + + if (response.data.length > 0) { + const withMisrepresented = response.data.filter((p) => p.misrepresentedTokens === true); + const withoutMisrepresented = response.data.filter((p) => p.misrepresentedTokens === false); + + // Verify all protocols have the boolean flag + response.data.forEach((protocol) => { + expect(typeof protocol.misrepresentedTokens).toBe('boolean'); + }); + + // If there are misrepresented tokens, verify structure + if (withMisrepresented.length > 0) { + withMisrepresented.slice(0, 5).forEach((protocol) => { + expect(protocol.misrepresentedTokens).toBe(true); + expectNonEmptyString(protocol.name); + }); + } + } + }); + + it('should have valid categories', () => { + // Use first test symbol + const response = symbolResponses[testSymbols[0]]; + + if (response.data.length > 0) { + const categories = new Set(response.data.map((p) => p.category)); + expect(categories.size).toBeGreaterThan(0); + + // Sample-based testing - validate first 10 protocols + response.data.slice(0, 10).forEach((protocol) => { + expect(typeof protocol.category).toBe('string'); + expectNonEmptyString(protocol.category); + }); + } + }); + }); +}); + diff --git a/defi/api-tests/src/tvl/tvl.test.ts b/defi/api-tests/src/tvl/tvl.test.ts new file mode 100644 index 0000000000..1746ce52e8 --- /dev/null +++ b/defi/api-tests/src/tvl/tvl.test.ts @@ -0,0 +1,84 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { ProtocolTvl, isProtocolTvl } from './types'; +import { protocolTvlSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.TVL.BASE_URL); +const TVL_ENDPOINTS = endpoints.TVL; + +describe('TVL API - Protocol TVL', () => { + const testProtocols = ['uniswap-v3']; + const tvlResponses: Record> = {}; + + beforeAll(async () => { + // Fetch all test protocols in parallel once + await Promise.all( + testProtocols.map(async (slug) => { + tvlResponses[slug] = await apiClient.get( + TVL_ENDPOINTS.TVL(slug) + ); + }) + ); + }, 60000); + + describe('Basic Response Validation', () => { + testProtocols.forEach((protocolSlug) => { + describe(`Protocol: ${protocolSlug}`, () => { + it('should return successful response', () => { + const response = tvlResponses[protocolSlug]; + expectSuccessfulResponse(response); + }); + + it('should return a number', () => { + const response = tvlResponses[protocolSlug]; + expect(typeof response.data).toBe('number'); + expect(isProtocolTvl(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const response = tvlResponses[protocolSlug]; + const result = validate(response.data, protocolTvlSchema, `ProtocolTvl-${protocolSlug}`); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors:', result.errors); + } + }); + + it('should have valid TVL value', () => { + const response = tvlResponses[protocolSlug]; + expectValidNumber(response.data); + expectNonNegativeNumber(response.data); + expect(response.data).toBeLessThan(10_000_000_000_000); // Max reasonable TVL + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent protocol gracefully', async () => { + const response = await apiClient.get( + TVL_ENDPOINTS.TVL('non-existent-protocol-xyz-123') + ); + + expect(response.status).toBeGreaterThanOrEqual(400); + }); + + it('should handle parent protocols', () => { + // Use first test protocol + const response = tvlResponses[testProtocols[0]]; + + if (response.status === 200) { + expectValidNumber(response.data); + expectNonNegativeNumber(response.data); + } + }); + }); +}); + diff --git a/defi/api-tests/src/tvl/types.ts b/defi/api-tests/src/tvl/types.ts new file mode 100644 index 0000000000..7133458bce --- /dev/null +++ b/defi/api-tests/src/tvl/types.ts @@ -0,0 +1,103 @@ +// TypeScript types for TVL API endpoints +// These types are inferred from Zod schemas to ensure consistency +// Source reference: defi/src/protocols/types.ts + +import { z } from 'zod'; +import { + protocolSchema, + protocolDetailsSchema, + chartDataPointSchema, + historicalDataPointSchema, + historicalTvlPointSchema, + historicalChainTvlPointSchema, + historicalChainTvlArraySchema, + chainSchema, + chainTvlSchema, + tokenTvlSchema, + protocolTvlSchema, + chainsV2ArraySchema, + chainAssetsSchema, + tokenProtocolSchema, + tokenProtocolsArraySchema, + inflowsSchema, + tokenTvlDataSchema, +} from './schemas'; + +export type Protocol = z.infer; +export type ProtocolDetails = z.infer; +export type ChartDataPoint = z.infer; +export type HistoricalDataPoint = z.infer; +export type HistoricalTvlPoint = z.infer; +export type HistoricalChainTvlPoint = z.infer; +export type HistoricalChainTvl = z.infer; +export type Chain = z.infer; +export type ChainTvl = z.infer; +export type TokenTvl = z.infer; +export type ProtocolTvl = z.infer; +export type ChainsV2 = z.infer; +export type ChainAssets = z.infer; +export type TokenProtocol = z.infer; +export type TokenProtocols = z.infer; +export type Inflows = z.infer; +export type TokenTvlData = z.infer; + + +// ============================================================================ +// Type Guards +// ============================================================================ + +export function isProtocol(data: unknown): data is Protocol { + return protocolSchema.safeParse(data).success; +} + +export function isProtocolArray(data: unknown): data is Protocol[] { + return Array.isArray(data) && (data.length === 0 || isProtocol(data[0])); +} + +export function isProtocolDetails(data: unknown): data is ProtocolDetails { + return protocolDetailsSchema.safeParse(data).success; +} + +export function isChartDataPoint(data: unknown): data is ChartDataPoint { + return chartDataPointSchema.safeParse(data).success; +} + +export function isChartDataArray(data: unknown): data is ChartDataPoint[] { + return Array.isArray(data) && (data.length === 0 || isChartDataPoint(data[0])); +} + +export function isChain(data: unknown): data is Chain { + return chainSchema.safeParse(data).success; +} + +export function isHistoricalChainTvlPoint(data: unknown): data is HistoricalChainTvlPoint { + return historicalChainTvlPointSchema.safeParse(data).success; +} + +export function isHistoricalChainTvl(data: unknown): data is HistoricalChainTvl { + return historicalChainTvlArraySchema.safeParse(data).success; +} + +export function isProtocolTvl(data: unknown): data is ProtocolTvl { + return protocolTvlSchema.safeParse(data).success; +} + +export function isChainsV2(data: unknown): data is ChainsV2 { + return chainsV2ArraySchema.safeParse(data).success; +} + +export function isChainAssets(data: unknown): data is ChainAssets { + return chainAssetsSchema.safeParse(data).success; +} + +export function isTokenProtocol(data: unknown): data is TokenProtocol { + return tokenProtocolSchema.safeParse(data).success; +} + +export function isTokenProtocols(data: unknown): data is TokenProtocols { + return tokenProtocolsArraySchema.safeParse(data).success; +} + +export function isInflows(data: unknown): data is Inflows { + return inflowsSchema.safeParse(data).success; +} diff --git a/defi/api-tests/src/unlocks/emission.test.ts b/defi/api-tests/src/unlocks/emission.test.ts new file mode 100644 index 0000000000..a3e8a618a6 --- /dev/null +++ b/defi/api-tests/src/unlocks/emission.test.ts @@ -0,0 +1,245 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { EmissionResponse, EmissionBody, isEmissionResponse } from './types'; +import { emissionResponseSchema, emissionBodySchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectValidTimestamp, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.UNLOCKS.BASE_URL); + +describe('Unlocks API - Emission', () => { + // Test with multiple protocols to ensure robustness + const testProtocols = ['overnight', 'uniswap']; + + testProtocols.forEach((protocol) => { + describe(`Protocol: ${protocol}`, () => { + let emissionResponse: ApiResponse; + let parsedBody: EmissionBody; + + beforeAll(async () => { + emissionResponse = await apiClient.get( + endpoints.UNLOCKS.EMISSION(protocol) + ); + + // Parse the body JSON string + if (emissionResponse.status === 200 && emissionResponse.data.body) { + try { + parsedBody = JSON.parse(emissionResponse.data.body); + } catch (e) { + console.error(`Failed to parse body for ${protocol}:`, e); + } + } + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(emissionResponse); + expect(isEmissionResponse(emissionResponse.data)).toBe(true); + expect(emissionResponse.data).toHaveProperty('body'); + expect(typeof emissionResponse.data.body).toBe('string'); + }); + + it('should validate against Zod schema', () => { + const result = validate( + emissionResponse.data, + emissionResponseSchema, + `Emission-${protocol}` + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should have valid lastModified field', () => { + if (emissionResponse.data.lastModified !== undefined) { + expect(emissionResponse.data.lastModified).toBeDefined(); + } + }); + + it('should have parseable body JSON', () => { + expect(() => JSON.parse(emissionResponse.data.body)).not.toThrow(); + expect(parsedBody).toBeDefined(); + }); + }); + + describe('Parsed Body Validation', () => { + it('should validate parsed body against schema', () => { + if (parsedBody) { + const result = validate( + parsedBody, + emissionBodySchema, + `EmissionBody-${protocol}` + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Body validation errors:', result.errors.slice(0, 5)); + } + } + }); + + it('should have documentedData when present', () => { + if (parsedBody && parsedBody.documentedData) { + expect(parsedBody.documentedData).toHaveProperty('data'); + expect(Array.isArray(parsedBody.documentedData.data)).toBe(true); + } + }); + + it('should have valid emission series data', () => { + if (parsedBody && parsedBody.documentedData && parsedBody.documentedData.data) { + const series = parsedBody.documentedData.data; + expect(series.length).toBeGreaterThan(0); + + series.slice(0, 5).forEach((seriesItem) => { + expect(seriesItem).toHaveProperty('label'); + expect(seriesItem).toHaveProperty('data'); + expect(typeof seriesItem.label).toBe('string'); + expect(Array.isArray(seriesItem.data)).toBe(true); + }); + } + }); + + it('should have valid data points in series', () => { + if (parsedBody && parsedBody.documentedData && parsedBody.documentedData.data) { + const series = parsedBody.documentedData.data; + + if (series.length > 0 && series[0].data.length > 0) { + const dataPoints = series[0].data.slice(0, 10); + + dataPoints.forEach((point) => { + expect(point).toHaveProperty('timestamp'); + expect(point).toHaveProperty('unlocked'); + + expectValidNumber(point.timestamp); + expectValidNumber(point.unlocked); + expectValidTimestamp(point.timestamp); + + if (point.rawEmission !== undefined) { + expectValidNumber(point.rawEmission); + } + + if (point.burned !== undefined) { + expectValidNumber(point.burned); + } + }); + } + } + }); + + it('should have chronologically ordered timestamps', () => { + if (parsedBody && parsedBody.documentedData && parsedBody.documentedData.data) { + const series = parsedBody.documentedData.data; + + if (series.length > 0 && series[0].data.length > 1) { + const timestamps = series[0].data.map((point) => point.timestamp); + + for (let i = 0; i < timestamps.length - 1; i++) { + expect(timestamps[i]).toBeLessThanOrEqual(timestamps[i + 1]); + } + } + } + }); + + it('should have non-negative unlocked values', () => { + if (parsedBody && parsedBody.documentedData && parsedBody.documentedData.data) { + const series = parsedBody.documentedData.data; + + if (series.length > 0) { + series[0].data.slice(0, 20).forEach((point) => { + expect(point.unlocked).toBeGreaterThanOrEqual(0); + }); + } + } + }); + + it('should have categories array when present', () => { + if (parsedBody && parsedBody.documentedData && parsedBody.documentedData.categories) { + expect(Array.isArray(parsedBody.documentedData.categories)).toBe(true); + + if (parsedBody.documentedData.categories.length > 0) { + parsedBody.documentedData.categories.forEach((category) => { + expect(typeof category).toBe('string'); + }); + } + } + }); + + it('should have sources array when present', () => { + if (parsedBody && parsedBody.documentedData && parsedBody.documentedData.sources) { + expect(Array.isArray(parsedBody.documentedData.sources)).toBe(true); + + if (parsedBody.documentedData.sources.length > 0) { + parsedBody.documentedData.sources.forEach((source) => { + expect(typeof source).toBe('string'); + }); + } + } else if (parsedBody && parsedBody.sources) { + expect(Array.isArray(parsedBody.sources)).toBe(true); + } + }); + }); + + describe('Data Quality Validation', () => { + it('should have reasonable timestamp range', () => { + if (parsedBody && parsedBody.documentedData && parsedBody.documentedData.data) { + const series = parsedBody.documentedData.data; + + if (series.length > 0 && series[0].data.length > 0) { + const timestamps = series[0].data.map((point) => point.timestamp); + const minTimestamp = Math.min(...timestamps); + const maxTimestamp = Math.max(...timestamps); + + // Should cover a reasonable time range (at least a few months) + expect(maxTimestamp - minTimestamp).toBeGreaterThan(86400 * 30); + + // Should not be in the distant future (more than 10 years) + expect(maxTimestamp).toBeLessThan(Date.now() / 1000 + 86400 * 365 * 10); + } + } + }); + + it('should have increasing or stable unlocked amounts over time', () => { + if (parsedBody && parsedBody.documentedData && parsedBody.documentedData.data) { + const series = parsedBody.documentedData.data; + + if (series.length > 0 && series[0].data.length > 1) { + const data = series[0].data; + let prevUnlocked = data[0].unlocked; + + // Check that unlocked generally doesn't decrease (allowing for small variations) + for (let i = 1; i < Math.min(data.length, 100); i++) { + // Unlocked should generally increase or stay the same + // Allow for small decreases (< 1%) due to data inconsistencies + if (prevUnlocked > 0) { + const decreaseRatio = (prevUnlocked - data[i].unlocked) / prevUnlocked; + expect(decreaseRatio).toBeLessThan(0.5); // Should not decrease by more than 50% + } + prevUnlocked = data[i].unlocked; + } + } + } + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent protocol gracefully', async () => { + const response = await apiClient.get( + endpoints.UNLOCKS.EMISSION('non-existent-protocol-xyz-123') + ); + + // API might return 404 or 200 with empty/null data + if (response.status === 200) { + expect(response.data).toBeDefined(); + } else { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }); + }); +}); diff --git a/defi/api-tests/src/unlocks/emissions.test.ts b/defi/api-tests/src/unlocks/emissions.test.ts new file mode 100644 index 0000000000..c684f6d7f5 --- /dev/null +++ b/defi/api-tests/src/unlocks/emissions.test.ts @@ -0,0 +1,224 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { EmissionsResponse, isEmissionsResponse } from './types'; +import { emissionsResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectArrayResponse, + expectValidNumber, + expectNonNegativeNumber, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.UNLOCKS.BASE_URL); + +describe('Unlocks API - Emissions', () => { + let emissionsResponse: ApiResponse; + + beforeAll(async () => { + emissionsResponse = await apiClient.get( + endpoints.UNLOCKS.EMISSIONS + ); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(emissionsResponse); + expectArrayResponse(emissionsResponse); + expect(isEmissionsResponse(emissionsResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate( + emissionsResponse.data, + emissionsResponseSchema, + 'Emissions' + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should return a non-empty array', () => { + expect(emissionsResponse.data.length).toBeGreaterThan(0); + }); + + it('should have minimum expected protocols', () => { + expect(emissionsResponse.data.length).toBeGreaterThan(10); + }); + }); + + describe('Emission Item Validation', () => { + it('should have required fields in all items', () => { + emissionsResponse.data.slice(0, 20).forEach((item) => { + expect(item).toHaveProperty('token'); + expect(item).toHaveProperty('protocolId'); + expect(item).toHaveProperty('name'); + + expect(typeof item.token).toBe('string'); + expect(typeof item.protocolId).toBe('string'); + expect(typeof item.name).toBe('string'); + expect(item.name.length).toBeGreaterThan(0); + }); + }); + + it('should have valid sources array when present', () => { + const itemsWithSources = emissionsResponse.data + .filter((item) => item.sources !== undefined) + .slice(0, 20); + + if (itemsWithSources.length > 0) { + itemsWithSources.forEach((item) => { + expect(Array.isArray(item.sources)).toBe(true); + if (item.sources && item.sources.length > 0) { + item.sources.forEach((source) => { + expect(typeof source).toBe('string'); + }); + } + }); + } + }); + + it('should have valid numeric fields when present', () => { + const itemsWithData = emissionsResponse.data + .filter((item) => item.circSupply !== undefined) + .slice(0, 20); + + expect(itemsWithData.length).toBeGreaterThan(0); + + itemsWithData.forEach((item) => { + if (item.circSupply !== undefined) { + expectValidNumber(item.circSupply); + expectNonNegativeNumber(item.circSupply); + } + + if (item.circSupply30d !== undefined) { + expectValidNumber(item.circSupply30d); + expectNonNegativeNumber(item.circSupply30d); + } + + if (item.totalLocked !== undefined) { + expectValidNumber(item.totalLocked); + expectNonNegativeNumber(item.totalLocked); + } + + if (item.mcap !== undefined) { + expectValidNumber(item.mcap); + expectNonNegativeNumber(item.mcap); + } + + if (item.maxSupply !== undefined) { + expectValidNumber(item.maxSupply); + expectNonNegativeNumber(item.maxSupply); + } + + if (item.unlockedPct !== undefined) { + expectValidNumber(item.unlockedPct); + expect(item.unlockedPct).toBeGreaterThanOrEqual(0); + expect(item.unlockedPct).toBeLessThanOrEqual(100); + } + }); + }); + + it('should have valid nextEvent when present', () => { + const itemsWithNextEvent = emissionsResponse.data + .filter((item) => item.nextEvent !== undefined) + .slice(0, 20); + + if (itemsWithNextEvent.length > 0) { + itemsWithNextEvent.forEach((item) => { + const eventTime = item.nextEvent!.timestamp || item.nextEvent!.date; + expect(eventTime).toBeDefined(); + expect(typeof eventTime).toBe('number'); + expect(eventTime!).toBeGreaterThan(0); + + if (item.nextEvent!.noOfTokens !== undefined) { + expectValidNumber(item.nextEvent!.noOfTokens); + } + + if (item.nextEvent!.toUnlock !== undefined) { + expectValidNumber(item.nextEvent!.toUnlock); + } + + if (item.nextEvent!.proportion !== undefined) { + expectValidNumber(item.nextEvent!.proportion); + } + }); + } + }); + + it('should have valid token field format', () => { + emissionsResponse.data.slice(0, 20).forEach((item) => { + // Token can be in formats: "chain:0xaddress" or "coingecko:symbol" + // SUI addresses can contain :: so we need to allow multiple colons in the address part + expect(item.token).toMatch(/^[\w-]+:.+$/); + expect(item.token.split(':').length).toBeGreaterThanOrEqual(2); + + // Check that there's at least a chain and an address/id part + const parts = item.token.split(':'); + expect(parts[0].length).toBeGreaterThan(0); // Chain part + expect(parts.slice(1).join(':').length).toBeGreaterThan(0); // Address/ID part (may contain colons) + }); + }); + + it('should have unique protocol IDs', () => { + const protocolIds = emissionsResponse.data.map((item) => item.protocolId); + const uniqueIds = new Set(protocolIds); + expect(uniqueIds.size).toBe(protocolIds.length); + }); + }); + + describe('Data Quality Validation', () => { + it('should have items with circulating supply data', () => { + const itemsWithCircSupply = emissionsResponse.data.filter( + (item) => item.circSupply !== undefined && item.circSupply > 0 + ); + + expect(itemsWithCircSupply.length).toBeGreaterThan(0); + }); + + it('should have items with locked tokens', () => { + const itemsWithLocked = emissionsResponse.data.filter( + (item) => item.totalLocked !== undefined && item.totalLocked > 0 + ); + + expect(itemsWithLocked.length).toBeGreaterThan(0); + }); + + it('should have items with upcoming events', () => { + const itemsWithEvents = emissionsResponse.data.filter( + (item) => item.nextEvent !== undefined + ); + + // Not all protocols may have upcoming events + if (itemsWithEvents.length > 0) { + const eventTime = itemsWithEvents[0].nextEvent!.timestamp || itemsWithEvents[0].nextEvent!.date; + expect(eventTime).toBeDefined(); + expect(eventTime!).toBeGreaterThan( + Math.floor(Date.now() / 1000) - 86400 * 365 // Within reasonable range + ); + } + }); + + it('should have reasonable circulating supply changes', () => { + const itemsWithBothSupplies = emissionsResponse.data.filter( + (item) => + item.circSupply !== undefined && + item.circSupply30d !== undefined && + item.circSupply > 0 && + item.circSupply30d > 0 + ); + + if (itemsWithBothSupplies.length > 0) { + itemsWithBothSupplies.slice(0, 10).forEach((item) => { + // Supply should be in reasonable range (not changed by more than 10x in 30 days) + const ratio = item.circSupply! / item.circSupply30d!; + expect(ratio).toBeGreaterThan(0.1); + expect(ratio).toBeLessThan(10); + }); + } + }); + }); +}); diff --git a/defi/api-tests/src/unlocks/schemas.ts b/defi/api-tests/src/unlocks/schemas.ts new file mode 100644 index 0000000000..f084eff425 --- /dev/null +++ b/defi/api-tests/src/unlocks/schemas.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; + +// Schema for emissions list endpoint +export const emissionItemSchema = z.object({ + token: z.string(), + sources: z.array(z.string()).optional(), + protocolId: z.string(), + name: z.string(), + symbol: z.string().optional(), + gecko_id: z.union([z.string(), z.null()]).optional(), + circSupply: z.number().optional(), + circSupply30d: z.number().optional(), + totalLocked: z.number().optional(), + nextEvent: z.object({ + date: z.number().optional(), + timestamp: z.number().optional(), + toUnlock: z.number().optional(), + noOfTokens: z.number().optional(), + proportion: z.number().optional(), + }).optional(), + tokenPrice: z.union([z.number(), z.string()]).optional(), + mcap: z.number().optional(), + maxSupply: z.number().optional(), + unlockedPct: z.number().optional(), + allocation: z.record(z.string(), z.any()).optional(), +}); + +export const emissionsResponseSchema = z.array(emissionItemSchema); + +// Schema for individual emission endpoint +export const emissionDataPointSchema = z.object({ + timestamp: z.number(), + unlocked: z.number(), + rawEmission: z.number().optional(), + burned: z.number().optional(), +}); + +export const emissionSeriesSchema = z.object({ + label: z.string(), + data: z.array(emissionDataPointSchema), +}); + +export const documentedDataSchema = z.object({ + data: z.array(emissionSeriesSchema), + categories: z.array(z.string()).optional(), + sources: z.array(z.string()).optional(), + notes: z.array(z.string()).optional(), + events: z.array(z.any()).optional(), +}); + +// The body is a JSON string, so we'll parse it separately +export const emissionResponseSchema = z.object({ + body: z.string(), + lastModified: z.union([z.string(), z.number()]).optional(), +}); + +// After parsing the body JSON +export const emissionBodySchema = z.object({ + documentedData: documentedDataSchema.optional(), + chainData: z.record(z.string(), z.any()).optional(), + metadata: z.record(z.string(), z.any()).optional(), + token: z.string().optional(), + sources: z.array(z.string()).optional(), +}); + diff --git a/defi/api-tests/src/unlocks/types.ts b/defi/api-tests/src/unlocks/types.ts new file mode 100644 index 0000000000..1e320ebf81 --- /dev/null +++ b/defi/api-tests/src/unlocks/types.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { + emissionItemSchema, + emissionsResponseSchema, + emissionDataPointSchema, + emissionSeriesSchema, + documentedDataSchema, + emissionResponseSchema, + emissionBodySchema, +} from './schemas'; + +// Infer types from schemas +export type EmissionItem = z.infer; +export type EmissionsResponse = z.infer; + +export type EmissionDataPoint = z.infer; +export type EmissionSeries = z.infer; +export type DocumentedData = z.infer; +export type EmissionResponse = z.infer; +export type EmissionBody = z.infer; + +// Type guards +export function isEmissionsResponse(data: unknown): data is EmissionsResponse { + return emissionsResponseSchema.safeParse(data).success; +} + +export function isEmissionResponse(data: unknown): data is EmissionResponse { + return emissionResponseSchema.safeParse(data).success; +} + +export function isEmissionBody(data: unknown): data is EmissionBody { + return emissionBodySchema.safeParse(data).success; +} + diff --git a/defi/api-tests/src/users/activeUsers.test.ts b/defi/api-tests/src/users/activeUsers.test.ts new file mode 100644 index 0000000000..e2eea08ebf --- /dev/null +++ b/defi/api-tests/src/users/activeUsers.test.ts @@ -0,0 +1,234 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { ActiveUsersResponse, isActiveUsersResponse } from './types'; +import { activeUsersResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectObjectResponse, + expectValidNumber, + expectNonNegativeNumber, + expectNonEmptyString, + expectValidTimestamp, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.USERS.BASE_URL); + +describe('Active Users API - Active Users', () => { + let activeUsersResponse: ApiResponse; + + beforeAll(async () => { + activeUsersResponse = await apiClient.get(endpoints.USERS.ACTIVE_USERS); + }, 60000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(activeUsersResponse); + expectObjectResponse(activeUsersResponse); + expect(isActiveUsersResponse(activeUsersResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate(activeUsersResponse.data, activeUsersResponseSchema, 'ActiveUsers'); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should have minimum number of protocols', () => { + const protocolIds = Object.keys(activeUsersResponse.data); + expect(protocolIds.length).toBeGreaterThan(10); + }); + + it('should have valid protocol IDs as keys', () => { + const protocolIds = Object.keys(activeUsersResponse.data); + + // Sample-based testing - validate first 10 protocol IDs + protocolIds.slice(0, 10).forEach((protocolId) => { + expectNonEmptyString(protocolId); + }); + }); + }); + + describe('Data Validation', () => { + it('should have name field for protocol entries', () => { + // Filter for protocol entries (not chain aggregates like "chain#ethereum") + const protocolEntries = Object.entries(activeUsersResponse.data).filter( + ([id]) => !id.startsWith('chain#') + ); + expect(protocolEntries.length).toBeGreaterThan(0); + + // Sample-based testing - validate first 10 protocol entries + protocolEntries.slice(0, 10).forEach(([, data]) => { + if (data.name) { + expectNonEmptyString(data.name); + } + }); + }); + + it('should have valid users metric when present', () => { + const protocolsWithUsers = Object.entries(activeUsersResponse.data).filter( + ([, data]) => data.users !== undefined + ); + expect(protocolsWithUsers.length).toBeGreaterThan(0); + + // Sample-based testing - validate first 10 protocols with users + protocolsWithUsers.slice(0, 10).forEach(([, data]) => { + expect(data.users).toHaveProperty('value'); + expect(data.users).toHaveProperty('end'); + + // Value can be number or string + const userValue = typeof data.users!.value === 'string' + ? Number(data.users!.value) + : data.users!.value; + expectValidNumber(userValue); + expectNonNegativeNumber(userValue); + expect(userValue).toBeLessThan(1_000_000_000); + + // End should be a valid timestamp + expectValidTimestamp(data.users!.end); + }); + }); + + it('should have valid txs metric when present', () => { + const protocolsWithTxs = Object.entries(activeUsersResponse.data).filter( + ([, data]) => data.txs !== undefined + ); + + if (protocolsWithTxs.length > 0) { + // Sample-based testing - validate first 10 protocols + protocolsWithTxs.slice(0, 10).forEach(([, data]) => { + expect(data.txs).toHaveProperty('value'); + expect(data.txs).toHaveProperty('end'); + + // Value can be number or string + const txsValue = typeof data.txs!.value === 'string' + ? Number(data.txs!.value) + : data.txs!.value; + expectValidNumber(txsValue); + expectNonNegativeNumber(txsValue); + + expectValidTimestamp(data.txs!.end); + }); + } + }); + + it('should have valid gasUsd metric when present', () => { + const protocolsWithGas = Object.entries(activeUsersResponse.data).filter( + ([, data]) => data.gasUsd !== undefined + ); + + if (protocolsWithGas.length > 0) { + // Sample-based testing - validate first 10 protocols + protocolsWithGas.slice(0, 10).forEach(([, data]) => { + expect(data.gasUsd).toHaveProperty('value'); + expect(data.gasUsd).toHaveProperty('end'); + + const gasValue = typeof data.gasUsd!.value === 'string' + ? Number(data.gasUsd!.value) + : data.gasUsd!.value; + expectValidNumber(gasValue); + expectNonNegativeNumber(gasValue); + + expectValidTimestamp(data.gasUsd!.end); + }); + } + }); + + it('should have valid change percentages when present', () => { + const protocolsWithChange = Object.entries(activeUsersResponse.data).filter( + ([, data]) => data.change_1d !== undefined || data.change_7d !== undefined || data.change_1m !== undefined + ); + + if (protocolsWithChange.length > 0) { + // Sample-based testing - validate first 10 protocols + protocolsWithChange.slice(0, 10).forEach(([, data]) => { + if (data.change_1d !== null && data.change_1d !== undefined) { + // Change can be a number or a metric object + if (typeof data.change_1d === 'number') { + expectValidNumber(data.change_1d); + expect(Math.abs(data.change_1d)).toBeLessThan(1000); + } + } + if (data.change_7d !== null && data.change_7d !== undefined) { + if (typeof data.change_7d === 'number') { + expectValidNumber(data.change_7d); + expect(Math.abs(data.change_7d)).toBeLessThan(1000); + } + } + if (data.change_1m !== null && data.change_1m !== undefined) { + if (typeof data.change_1m === 'number') { + expectValidNumber(data.change_1m); + expect(Math.abs(data.change_1m)).toBeLessThan(1000); + } + } + }); + } + }); + }); + + describe('Edge Cases', () => { + it('should handle protocols with null change values', () => { + const protocolsWithNullChange = Object.entries(activeUsersResponse.data).filter( + ([, data]) => data.change_1d === null || data.change_7d === null || data.change_1m === null + ); + + if (protocolsWithNullChange.length > 0) { + protocolsWithNullChange.slice(0, 5).forEach(([, data]) => { + // Null values should be acceptable + if (data.change_1d === null) { + expect(data.change_1d).toBeNull(); + } + if (data.change_7d === null) { + expect(data.change_7d).toBeNull(); + } + if (data.change_1m === null) { + expect(data.change_1m).toBeNull(); + } + }); + } + }); + + it('should handle protocols with zero users', () => { + const protocolsWithZeroUsers = Object.entries(activeUsersResponse.data).filter( + ([, data]) => data.users && Number(data.users.value) === 0 + ); + + if (protocolsWithZeroUsers.length > 0) { + protocolsWithZeroUsers.slice(0, 5).forEach(([, data]) => { + const userValue = typeof data.users!.value === 'string' + ? Number(data.users!.value) + : data.users!.value; + expect(userValue).toBe(0); + expectNonEmptyString(data.name); + }); + } + }); + + it('should have consistent end timestamps across metrics for same protocol', () => { + const protocols = Object.entries(activeUsersResponse.data); + + // Sample-based testing - check first 10 protocols with multiple metrics + protocols.slice(0, 10).forEach(([, data]) => { + const timestamps: number[] = []; + + if (data.users) timestamps.push(data.users.end); + if (data.txs) timestamps.push(data.txs.end); + if (data.gasUsd) timestamps.push(data.gasUsd.end); + if (data.newUsers) timestamps.push(data.newUsers.end); + + if (timestamps.length > 1) { + // All timestamps should be the same (or very close) for a given protocol + const uniqueTimestamps = [...new Set(timestamps)]; + // Allow some variation (within 1 hour = 3600 seconds) + if (uniqueTimestamps.length > 1) { + const maxDiff = Math.max(...timestamps) - Math.min(...timestamps); + expect(maxDiff).toBeLessThan(3600); + } + } + }); + }); + }); +}); diff --git a/defi/api-tests/src/users/schemas.ts b/defi/api-tests/src/users/schemas.ts new file mode 100644 index 0000000000..403e65627a --- /dev/null +++ b/defi/api-tests/src/users/schemas.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +// ============================================================================ +// Active Users Schema (from /api/activeUsers endpoint) +// ============================================================================ + +// Metric value schema - each metric has a value and end timestamp +const metricValueSchema = z.object({ + value: z.union([z.number().finite(), z.string()]), // Can be number or string + end: z.number().finite(), // Unix timestamp +}).passthrough(); + +export const activeUserItemSchema = z.object({ + name: z.string().min(1).optional(), // Optional for chain aggregates + users: metricValueSchema.optional(), + txs: metricValueSchema.optional(), + gasUsd: metricValueSchema.optional(), + newUsers: metricValueSchema.optional(), + change_1d: z.union([z.number().finite(), metricValueSchema]).nullable().optional(), + change_7d: z.union([z.number().finite(), metricValueSchema]).nullable().optional(), + change_1m: z.union([z.number().finite(), metricValueSchema]).nullable().optional(), +}).passthrough(); // Allow additional fields + +// Response is a record with protocol IDs as keys +export const activeUsersResponseSchema = z.record(z.string(), activeUserItemSchema); + +// ============================================================================ +// User Data Schema (from /api/userData/{type}/{protocolId} endpoint) +// ============================================================================ + +// API returns array tuples: [timestamp, value] +// where value can be a number or string representation of a number +export const userDataPointSchema = z.tuple([ + z.number().finite(), // timestamp + z.union([z.number().finite(), z.string()]), // value (number or string) +]); + +export const userDataArraySchema = z.array(userDataPointSchema); + diff --git a/defi/api-tests/src/users/types.ts b/defi/api-tests/src/users/types.ts new file mode 100644 index 0000000000..b9bf201d0d --- /dev/null +++ b/defi/api-tests/src/users/types.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { + activeUserItemSchema, + activeUsersResponseSchema, + userDataPointSchema, + userDataArraySchema, +} from './schemas'; + +// ============================================================================ +// Active Users Types +// ============================================================================ + +export type ActiveUserItem = z.infer; +export type ActiveUsersResponse = z.infer; + +export function isActiveUsersResponse(data: unknown): data is ActiveUsersResponse { + return activeUsersResponseSchema.safeParse(data).success; +} + +// ============================================================================ +// User Data Types +// ============================================================================ + +// UserDataPoint is a tuple: [timestamp, value] +export type UserDataPoint = z.infer; +export type UserDataArray = z.infer; + +export function isUserDataArray(data: unknown): data is UserDataArray { + return userDataArraySchema.safeParse(data).success; +} + diff --git a/defi/api-tests/src/users/userData.test.ts b/defi/api-tests/src/users/userData.test.ts new file mode 100644 index 0000000000..d771b6349b --- /dev/null +++ b/defi/api-tests/src/users/userData.test.ts @@ -0,0 +1,262 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { UserDataArray, isUserDataArray } from './types'; +import { userDataArraySchema } from './schemas'; +import { + expectSuccessfulResponse, + expectArrayResponse, + expectValidNumber, + expectNonNegativeNumber, + expectValidTimestamp, + expectFreshData, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.USERS.BASE_URL); + +describe('Active Users API - User Data', () => { + // Configure test protocols and types - keep minimal for speed + const testProtocols = ['319']; // Protocol ID (Uniswap V3) + // const testProtocols = ['319', '1', '2522']; // Multiple protocols for thorough testing + + const testTypes = ['users', 'txs', 'newusers']; // Test all data types + + const userDataResponses: Record>> = {}; + + beforeAll(async () => { + // Fetch all test combinations in parallel + await Promise.all( + testProtocols.flatMap((protocol) => + testTypes.map(async (type) => { + if (!userDataResponses[protocol]) { + userDataResponses[protocol] = {}; + } + userDataResponses[protocol][type] = await apiClient.get( + endpoints.USERS.USER_DATA(type, protocol) + ); + }) + ) + ); + }, 60000); + + describe('Basic Response Validation', () => { + testProtocols.forEach((protocol) => { + testTypes.forEach((type) => { + describe(`Protocol: ${protocol}, Type: ${type}`, () => { + it('should return successful response with valid structure', () => { + const response = userDataResponses[protocol][type]; + expectSuccessfulResponse(response); + expectArrayResponse(response); + expect(isUserDataArray(response.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const response = userDataResponses[protocol][type]; + const result = validate( + response.data, + userDataArraySchema, + `UserData-${protocol}-${type}` + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should return an array (may be empty for some protocols)', () => { + const response = userDataResponses[protocol][type]; + expect(Array.isArray(response.data)).toBe(true); + // Note: Some protocols may return empty arrays if no data is available + expect(response.data.length).toBeGreaterThanOrEqual(0); + }); + }); + }); + }); + }); + + describe('Data Point Validation', () => { + testProtocols.forEach((protocol) => { + testTypes.forEach((type) => { + describe(`Protocol: ${protocol}, Type: ${type}`, () => { + it('should have required fields in all data points', () => { + const response = userDataResponses[protocol][type]; + expectSuccessfulResponse(response); + + if (response.data.length > 0) { + // Data points are tuples: [timestamp, value] + response.data.slice(0, 10).forEach((point) => { + expect(Array.isArray(point)).toBe(true); + expect(point.length).toBe(2); + expect(point[0]).toBeDefined(); // timestamp + expect(point[1]).toBeDefined(); // value + }); + } + }); + + it('should have valid timestamps', () => { + const response = userDataResponses[protocol][type]; + expectSuccessfulResponse(response); + + if (response.data.length > 0) { + // Timestamp is first element of tuple + response.data.slice(0, 10).forEach((point) => { + const timestamp = point[0]; + expect(typeof timestamp).toBe('number'); + expect(isNaN(timestamp)).toBe(false); + expectValidTimestamp(timestamp); + }); + } + }); + + it('should have valid metric values when present', () => { + const response = userDataResponses[protocol][type]; + expectSuccessfulResponse(response); + + if (response.data.length > 0) { + // Value is second element of tuple + response.data.slice(0, 10).forEach((point) => { + const value = typeof point[1] === 'string' ? Number(point[1]) : point[1]; + expectValidNumber(value); + expectNonNegativeNumber(value); + }); + } + }); + + it('should have data points in chronological order', () => { + const response = userDataResponses[protocol][type]; + expectSuccessfulResponse(response); + + if (response.data.length > 1) { + const timestamps = response.data.map((p) => p[0]); + const sortedTimestamps = [...timestamps].sort((a, b) => a - b); + expect(timestamps).toEqual(sortedTimestamps); + } + }); + }); + }); + }); + }); + + describe('Data Freshness Validation', () => { + testProtocols.forEach((protocol) => { + testTypes.forEach((type) => { + describe(`Protocol: ${protocol}, Type: ${type}`, () => { + it('should have fresh data (most recent timestamp within 7 days)', () => { + const response = userDataResponses[protocol][type]; + expectSuccessfulResponse(response); + + if (response.data.length > 0) { + // Timestamps are first element of each tuple + const timestamps = response.data.map((point) => point[0]); + // Active users data might update less frequently, allow 7 days + expectFreshData(timestamps, 7 * 86400); + } + }); + }); + }); + }); + }); + + describe('Type-Specific Validation', () => { + testProtocols.forEach((protocol) => { + describe(`Protocol: ${protocol}`, () => { + it('should return valid data for users type', () => { + if (testTypes.includes('users')) { + const response = userDataResponses[protocol]['users']; + expectSuccessfulResponse(response); + + if (response.data.length > 0) { + // Validate sample of tuples + response.data.slice(0, 10).forEach((point) => { + const value = typeof point[1] === 'string' ? Number(point[1]) : point[1]; + expectNonNegativeNumber(value); + }); + } + } + }); + + it('should return valid data for txs type', () => { + if (testTypes.includes('txs')) { + const response = userDataResponses[protocol]['txs']; + expectSuccessfulResponse(response); + + if (response.data.length > 0) { + // Validate sample of tuples + response.data.slice(0, 10).forEach((point) => { + const value = typeof point[1] === 'string' ? Number(point[1]) : point[1]; + expectNonNegativeNumber(value); + }); + } + } + }); + + it('should return valid data for newusers type', () => { + if (testTypes.includes('newusers')) { + const response = userDataResponses[protocol]['newusers']; + expectSuccessfulResponse(response); + + if (response.data.length > 0) { + // Validate sample of tuples + response.data.slice(0, 10).forEach((point) => { + const value = typeof point[1] === 'string' ? Number(point[1]) : point[1]; + expectNonNegativeNumber(value); + }); + } + } + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent protocol gracefully', async () => { + const response = await apiClient.get( + endpoints.USERS.USER_DATA('users', 'non-existent-protocol-xyz-123') + ); + + // API might return 200 with empty array or 4xx status + if (response.status === 200) { + expect(Array.isArray(response.data)).toBe(true); + } else { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }); + + it('should handle invalid type gracefully', async () => { + const response = await apiClient.get( + endpoints.USERS.USER_DATA('invalid-type-xyz', testProtocols[0]) + ); + + // API might return 200 with empty array or 4xx status + if (response.status === 200) { + expect(Array.isArray(response.data)).toBe(true); + } else { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }); + + it('should handle data points with zero values', () => { + testProtocols.forEach((protocol) => { + testTypes.forEach((type) => { + const response = userDataResponses[protocol][type]; + expectSuccessfulResponse(response); + + // Filter tuples where value is 0 + const withZeroValues = response.data.filter((p) => { + const value = typeof p[1] === 'string' ? Number(p[1]) : p[1]; + return value === 0; + }); + + if (withZeroValues.length > 0) { + withZeroValues.slice(0, 5).forEach((point) => { + expect(point[0]).toBeDefined(); // timestamp + expect(point[1]).toBeDefined(); // value (which is 0) + }); + } + }); + }); + }); + }); +}); + diff --git a/defi/api-tests/src/volumes/overviewDexs.test.ts b/defi/api-tests/src/volumes/overviewDexs.test.ts new file mode 100644 index 0000000000..05b20be3df --- /dev/null +++ b/defi/api-tests/src/volumes/overviewDexs.test.ts @@ -0,0 +1,243 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { VolumeOverviewResponse, isVolumeOverviewResponse } from './types'; +import { volumeOverviewResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, + expectFreshData, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.VOLUMES.BASE_URL); + +describe('Volumes API - Overview DEXs', () => { + let overviewResponse: ApiResponse; + let chainResponse: ApiResponse; + + beforeAll(async () => { + const [r1, r2] = await Promise.all([ + apiClient.get(endpoints.VOLUMES.OVERVIEW_DEXS), + apiClient.get(endpoints.VOLUMES.OVERVIEW_DEXS_CHAIN('ethereum')), + ]); + + overviewResponse = r1; + chainResponse = r2; + }, 30000); + + describe('All DEXs Overview', () => { + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(overviewResponse); + expect(isVolumeOverviewResponse(overviewResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = volumeOverviewResponseSchema.safeParse(overviewResponse.data); + if (!result.success) { + console.log('Validation errors:', JSON.stringify(result.error.issues.slice(0, 10), null, 2)); + } + expect(result.success).toBe(true); + }); + + it('should have protocols array with data', () => { + expect(Array.isArray(overviewResponse.data.protocols)).toBe(true); + expect(overviewResponse.data.protocols.length).toBeGreaterThan(10); + }); + + it('should have total data chart with data', () => { + expect(Array.isArray(overviewResponse.data.totalDataChart)).toBe(true); + expect(overviewResponse.data.totalDataChart.length).toBeGreaterThan(100); + }); + }); + + describe('Data Quality Validation', () => { + it('should have fresh data in chart', () => { + const timestamps = overviewResponse.data.totalDataChart.map((point) => point[0]); + expectFreshData(timestamps, 86400 * 2); // 2 days + }); + + it('should have valid total metrics', () => { + if (overviewResponse.data.total24h !== null && overviewResponse.data.total24h !== undefined) { + expectValidNumber(overviewResponse.data.total24h); + expectNonNegativeNumber(overviewResponse.data.total24h); + expect(overviewResponse.data.total24h).toBeGreaterThan(0); + } + + if (overviewResponse.data.total7d !== null && overviewResponse.data.total7d !== undefined) { + expectValidNumber(overviewResponse.data.total7d); + expectNonNegativeNumber(overviewResponse.data.total7d); + } + }); + + it('should have chronologically ordered chart data', () => { + const timestamps = overviewResponse.data.totalDataChart.map((point) => point[0]); + + for (let i = 1; i < timestamps.length; i++) { + expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]); + } + }); + + it('should have protocols sorted by volume', () => { + const protocolsWithVolume = overviewResponse.data.protocols + .filter((p) => p.total24h !== null && p.total24h !== undefined && Number(p.total24h) > 1000) + .slice(0, 20); + + if (protocolsWithVolume.length > 1) { + // Check if at least most protocols are sorted (allow some tolerance) + let sortedPairs = 0; + let totalPairs = 0; + + for (let i = 1; i < protocolsWithVolume.length; i++) { + const prev = Number(protocolsWithVolume[i - 1].total24h); + const curr = Number(protocolsWithVolume[i].total24h); + totalPairs++; + if (prev >= curr) sortedPairs++; + } + + // At least 65% should be sorted + const sortedPercentage = (sortedPairs / totalPairs) * 100; + expect(sortedPercentage).toBeGreaterThan(65); + } + }); + + it('should have multiple chains represented', () => { + if (overviewResponse.data.allChains) { + expect(overviewResponse.data.allChains.length).toBeGreaterThan(5); + } + }); + }); + + describe('Protocol Item Validation', () => { + it('should have required fields in protocols', () => { + overviewResponse.data.protocols.slice(0, 20).forEach((protocol) => { + expect(protocol).toHaveProperty('name'); + expect(protocol).toHaveProperty('module'); + expect(typeof protocol.name).toBe('string'); + expect(protocol.name.length).toBeGreaterThan(0); + }); + }); + + it('should have valid volume metrics when present', () => { + const protocolsWithVolume = overviewResponse.data.protocols + .filter((p) => p.total24h !== null && p.total24h !== undefined) + .slice(0, 20); + + expect(protocolsWithVolume.length).toBeGreaterThan(0); + + protocolsWithVolume.forEach((protocol) => { + const volume = Number(protocol.total24h); + expectValidNumber(volume); + expectNonNegativeNumber(volume); + }); + }); + + it('should have valid change percentages when present', () => { + const protocolsWithChanges = overviewResponse.data.protocols + .filter((p) => p.change_1d !== null && p.change_1d !== undefined) + .slice(0, 20); + + if (protocolsWithChanges.length > 0) { + protocolsWithChanges.forEach((protocol) => { + expectValidNumber(protocol.change_1d!); + expect(isNaN(protocol.change_1d!)).toBe(false); + expect(isFinite(protocol.change_1d!)).toBe(true); + }); + } + }); + + it('should have valid chains arrays when present', () => { + const protocolsWithChains = overviewResponse.data.protocols + .filter((p) => p.chains && p.chains.length > 0) + .slice(0, 20); + + expect(protocolsWithChains.length).toBeGreaterThan(0); + + protocolsWithChains.forEach((protocol) => { + expect(Array.isArray(protocol.chains)).toBe(true); + expect(protocol.chains!.length).toBeGreaterThan(0); + protocol.chains!.forEach((chain) => { + expect(typeof chain).toBe('string'); + expect(chain.length).toBeGreaterThan(0); + }); + }); + }); + }); + + describe('Chart Data Validation', () => { + it('should have valid chart data points', () => { + overviewResponse.data.totalDataChart.slice(0, 50).forEach((point) => { + expect(Array.isArray(point)).toBe(true); + expect(point.length).toBe(2); + + // Timestamp + expectValidNumber(point[0]); + expect(point[0]).toBeGreaterThan(1400000000); // After May 2014 + + // Value + const value = typeof point[1] === 'string' ? Number(point[1]) : point[1]; + expectValidNumber(value); + expectNonNegativeNumber(value); + }); + }); + + it('should have reasonable time coverage', () => { + const timestamps = overviewResponse.data.totalDataChart.map((point) => point[0]); + const minTime = Math.min(...timestamps); + const maxTime = Math.max(...timestamps); + const spanDays = (maxTime - minTime) / 86400; + + expect(spanDays).toBeGreaterThan(30); // At least 30 days + }); + }); + }); + + describe('Specific Chain DEXs Overview', () => { + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(chainResponse); + expect(isVolumeOverviewResponse(chainResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = volumeOverviewResponseSchema.safeParse(chainResponse.data); + if (!result.success) { + console.log('Validation errors:', JSON.stringify(result.error.issues.slice(0, 10), null, 2)); + } + expect(result.success).toBe(true); + }); + + it('should have chain field set', () => { + expect(chainResponse.data.chain).toBe('Ethereum'); + }); + }); + + describe('Data Quality Validation', () => { + it('should have protocols with data', () => { + expect(chainResponse.data.protocols.length).toBeGreaterThan(5); + }); + + it('should have fresh chart data', () => { + const timestamps = chainResponse.data.totalDataChart.map((point) => point[0]); + expectFreshData(timestamps, 86400 * 2); // 2 days + }); + + it('should have protocols for the correct chain', () => { + const protocolsWithChains = chainResponse.data.protocols + .filter((p) => p.chains && p.chains.length > 0) + .slice(0, 10); + + if (protocolsWithChains.length > 0) { + // At least some protocols should include Ethereum + const hasEthereumProtocols = protocolsWithChains.some( + (p) => p.chains!.includes('Ethereum') + ); + expect(hasEthereumProtocols).toBe(true); + } + }); + }); + }); +}); + diff --git a/defi/api-tests/src/volumes/overviewOptions.test.ts b/defi/api-tests/src/volumes/overviewOptions.test.ts new file mode 100644 index 0000000000..6ee3ce84c4 --- /dev/null +++ b/defi/api-tests/src/volumes/overviewOptions.test.ts @@ -0,0 +1,242 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { VolumeOverviewResponse, isVolumeOverviewResponse } from './types'; +import { volumeOverviewResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, + expectFreshData, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.VOLUMES.BASE_URL); + +describe('Volumes API - Overview Options', () => { + let overviewResponse: ApiResponse; + let chainResponse: ApiResponse; + + beforeAll(async () => { + const [r1, r2] = await Promise.all([ + apiClient.get(endpoints.VOLUMES.OVERVIEW_OPTIONS), + apiClient.get(endpoints.VOLUMES.OVERVIEW_OPTIONS_CHAIN('ethereum')), + ]); + + overviewResponse = r1; + chainResponse = r2; + }, 30000); + + describe('All Options Overview', () => { + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(overviewResponse); + expect(isVolumeOverviewResponse(overviewResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = volumeOverviewResponseSchema.safeParse(overviewResponse.data); + if (!result.success) { + console.log('Validation errors:', JSON.stringify(result.error.issues.slice(0, 10), null, 2)); + } + expect(result.success).toBe(true); + }); + + it('should have protocols array with data', () => { + expect(Array.isArray(overviewResponse.data.protocols)).toBe(true); + expect(overviewResponse.data.protocols.length).toBeGreaterThan(0); + }); + + it('should have total data chart with data', () => { + expect(Array.isArray(overviewResponse.data.totalDataChart)).toBe(true); + expect(overviewResponse.data.totalDataChart.length).toBeGreaterThan(10); + }); + }); + + describe('Data Quality Validation', () => { + it('should have fresh data in chart', () => { + const timestamps = overviewResponse.data.totalDataChart.map((point) => point[0]); + expectFreshData(timestamps, 86400 * 3); // 3 days + }); + + it('should have valid total metrics', () => { + if (overviewResponse.data.total24h !== null && overviewResponse.data.total24h !== undefined) { + expectValidNumber(overviewResponse.data.total24h); + expectNonNegativeNumber(overviewResponse.data.total24h); + } + + if (overviewResponse.data.total7d !== null && overviewResponse.data.total7d !== undefined) { + expectValidNumber(overviewResponse.data.total7d); + expectNonNegativeNumber(overviewResponse.data.total7d); + } + }); + + it('should have chronologically ordered chart data', () => { + const timestamps = overviewResponse.data.totalDataChart.map((point) => point[0]); + + for (let i = 1; i < timestamps.length; i++) { + expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]); + } + }); + + it('should have protocols sorted by volume when volumes exist', () => { + const protocolsWithVolume = overviewResponse.data.protocols + .filter((p) => p.total24h !== null && p.total24h !== undefined && Number(p.total24h) > 100) + .slice(0, 10); + + if (protocolsWithVolume.length > 1) { + // Check if at least most protocols are sorted (allow some tolerance) + let sortedPairs = 0; + let totalPairs = 0; + + for (let i = 1; i < protocolsWithVolume.length; i++) { + const prev = Number(protocolsWithVolume[i - 1].total24h); + const curr = Number(protocolsWithVolume[i].total24h); + totalPairs++; + if (prev >= curr) sortedPairs++; + } + + // At least 30% should be sorted (options may have less strict sorting) + const sortedPercentage = (sortedPairs / totalPairs) * 100; + expect(sortedPercentage).toBeGreaterThan(30); + } + }); + + it('should have multiple protocols', () => { + expect(overviewResponse.data.protocols.length).toBeGreaterThan(2); + }); + }); + + describe('Protocol Item Validation', () => { + it('should have required fields in protocols', () => { + overviewResponse.data.protocols.slice(0, 10).forEach((protocol) => { + expect(protocol).toHaveProperty('name'); + expect(protocol).toHaveProperty('module'); + expect(typeof protocol.name).toBe('string'); + expect(protocol.name.length).toBeGreaterThan(0); + }); + }); + + it('should have valid volume metrics when present', () => { + const protocolsWithVolume = overviewResponse.data.protocols + .filter((p) => p.total24h !== null && p.total24h !== undefined) + .slice(0, 10); + + if (protocolsWithVolume.length > 0) { + protocolsWithVolume.forEach((protocol) => { + const volume = Number(protocol.total24h); + expectValidNumber(volume); + expectNonNegativeNumber(volume); + }); + } + }); + + it('should have valid change percentages when present', () => { + const protocolsWithChanges = overviewResponse.data.protocols + .filter((p) => p.change_1d !== null && p.change_1d !== undefined) + .slice(0, 10); + + if (protocolsWithChanges.length > 0) { + protocolsWithChanges.forEach((protocol) => { + expectValidNumber(protocol.change_1d!); + expect(isNaN(protocol.change_1d!)).toBe(false); + expect(isFinite(protocol.change_1d!)).toBe(true); + }); + } + }); + + it('should have valid chains arrays when present', () => { + const protocolsWithChains = overviewResponse.data.protocols + .filter((p) => p.chains && p.chains.length > 0) + .slice(0, 10); + + if (protocolsWithChains.length > 0) { + protocolsWithChains.forEach((protocol) => { + expect(Array.isArray(protocol.chains)).toBe(true); + expect(protocol.chains!.length).toBeGreaterThan(0); + protocol.chains!.forEach((chain) => { + expect(typeof chain).toBe('string'); + expect(chain.length).toBeGreaterThan(0); + }); + }); + } + }); + }); + + describe('Chart Data Validation', () => { + it('should have valid chart data points', () => { + overviewResponse.data.totalDataChart.slice(0, 30).forEach((point) => { + expect(Array.isArray(point)).toBe(true); + expect(point.length).toBe(2); + + // Timestamp + expectValidNumber(point[0]); + expect(point[0]).toBeGreaterThan(1400000000); // After May 2014 + + // Value + const value = typeof point[1] === 'string' ? Number(point[1]) : point[1]; + expectValidNumber(value); + expectNonNegativeNumber(value); + }); + }); + + it('should have reasonable time coverage', () => { + const timestamps = overviewResponse.data.totalDataChart.map((point) => point[0]); + const minTime = Math.min(...timestamps); + const maxTime = Math.max(...timestamps); + const spanDays = (maxTime - minTime) / 86400; + + expect(spanDays).toBeGreaterThan(7); // At least 7 days + }); + }); + }); + + describe('Specific Chain Options Overview', () => { + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(chainResponse); + expect(isVolumeOverviewResponse(chainResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = volumeOverviewResponseSchema.safeParse(chainResponse.data); + if (!result.success) { + console.log('Validation errors:', JSON.stringify(result.error.issues.slice(0, 10), null, 2)); + } + expect(result.success).toBe(true); + }); + + it('should have chain field set', () => { + expect(chainResponse.data.chain).toBe('Ethereum'); + }); + }); + + describe('Data Quality Validation', () => { + it('should have protocols with data', () => { + expect(chainResponse.data.protocols.length).toBeGreaterThan(0); + }); + + it('should have fresh chart data', () => { + if (chainResponse.data.totalDataChart.length > 0) { + const timestamps = chainResponse.data.totalDataChart.map((point) => point[0]); + expectFreshData(timestamps, 86400 * 3); // 3 days + } + }); + + it('should have protocols for the correct chain when chains specified', () => { + const protocolsWithChains = chainResponse.data.protocols + .filter((p) => p.chains && p.chains.length > 0) + .slice(0, 5); + + if (protocolsWithChains.length > 0) { + // At least some protocols should include Ethereum + const hasEthereumProtocols = protocolsWithChains.some( + (p) => p.chains!.includes('Ethereum') + ); + expect(hasEthereumProtocols).toBe(true); + } + }); + }); + }); +}); + diff --git a/defi/api-tests/src/volumes/schemas.ts b/defi/api-tests/src/volumes/schemas.ts new file mode 100644 index 0000000000..ccd4aa8249 --- /dev/null +++ b/defi/api-tests/src/volumes/schemas.ts @@ -0,0 +1,95 @@ +import { z } from 'zod'; + +// Protocol schema for overview responses +export const volumeProtocolSchema = z.object({ + name: z.string(), + disabled: z.boolean().optional().nullable(), + displayName: z.string().optional().nullable(), + module: z.string(), + category: z.string().optional().nullable(), + logo: z.string().optional().nullable(), + change_1d: z.union([z.number(), z.null()]).optional(), + change_7d: z.union([z.number(), z.null()]).optional(), + change_1m: z.union([z.number(), z.null()]).optional(), + change_7dover7d: z.union([z.number(), z.null()]).optional(), + change_30dover30d: z.union([z.number(), z.null()]).optional(), + total24h: z.union([z.number(), z.null()]).optional(), + total7d: z.union([z.number(), z.null()]).optional(), + total30d: z.union([z.number(), z.null()]).optional(), + totalAllTime: z.union([z.number(), z.null()]).optional(), + chains: z.array(z.string()).optional(), + protocolType: z.string().optional().nullable(), + methodologyURL: z.string().optional().nullable(), + methodology: z.union([z.string(), z.record(z.string(), z.string())]).optional().nullable(), + latestFetchIsOk: z.boolean().optional(), + dailyVolume: z.union([z.number(), z.string()]).optional().nullable(), + dailyUserFees: z.union([z.number(), z.string()]).optional().nullable(), + dailyRevenue: z.union([z.number(), z.string()]).optional().nullable(), + dailyHoldersRevenue: z.union([z.number(), z.string()]).optional().nullable(), + dailySupplySideRevenue: z.union([z.number(), z.string()]).optional().nullable(), + dailyProtocolRevenue: z.union([z.number(), z.string()]).optional().nullable(), + dailyPremiumVolume: z.union([z.number(), z.string()]).optional().nullable(), + defillamaId: z.string().optional().nullable(), + parentProtocol: z.string().optional().nullable(), +}); + +// Data chart point schema [timestamp, value] +export const dataChartPointSchema = z.tuple([ + z.number(), // timestamp + z.union([z.number(), z.string()]) // value (can be number or string) +]); + +// Overview response schema (for /overview/dexs and /overview/options) +export const volumeOverviewResponseSchema = z.object({ + protocols: z.array(volumeProtocolSchema), + totalDataChart: z.array(dataChartPointSchema), + totalDataChartBreakdown: z.array(z.array(z.any())).optional().nullable(), + allChains: z.array(z.string()).optional(), + chain: z.string().optional().nullable(), + total24h: z.union([z.number(), z.null()]).optional(), + total7d: z.union([z.number(), z.null()]).optional(), + change_1d: z.union([z.number(), z.null()]).optional(), + change_7d: z.union([z.number(), z.null()]).optional(), + change_1m: z.union([z.number(), z.null()]).optional(), +}); + +// Summary protocol response schema (for /summary/dexs/{protocol} and /summary/options/{protocol}) +export const volumeSummaryResponseSchema = z.object({ + id: z.string(), + name: z.string(), + url: z.string().optional().nullable(), + description: z.string().optional().nullable(), + logo: z.string().optional().nullable(), + gecko_id: z.string().optional().nullable(), + cmcId: z.string().optional().nullable(), + chains: z.array(z.string()), + twitter: z.string().optional().nullable(), + treasury: z.string().optional().nullable(), + governanceID: z.array(z.string()).optional().nullable(), + github: z.array(z.string()).optional().nullable(), + symbol: z.string().optional().nullable(), + tokenAddress: z.string().optional().nullable(), + address: z.string().optional().nullable(), + slug: z.string().optional().nullable(), + module: z.string().optional().nullable(), + category: z.string().optional().nullable(), + methodologyURL: z.string().optional().nullable(), + methodology: z.union([z.string(), z.record(z.string(), z.string())]).optional().nullable(), + protocolType: z.string().optional().nullable(), + disabled: z.boolean().optional().nullable(), + displayName: z.string().optional().nullable(), + latestFetchIsOk: z.boolean().optional(), + total24h: z.union([z.number(), z.null()]).optional(), + total7d: z.union([z.number(), z.null()]).optional(), + total30d: z.union([z.number(), z.null()]).optional(), + totalAllTime: z.union([z.number(), z.null()]).optional(), + change_1d: z.union([z.number(), z.null()]).optional(), + change_7d: z.union([z.number(), z.null()]).optional(), + change_1m: z.union([z.number(), z.null()]).optional(), + totalDataChart: z.array(dataChartPointSchema).optional(), + totalDataChartBreakdown: z.array(z.array(z.any())).optional().nullable(), + chainBreakdown: z.record(z.string(), z.any()).optional().nullable(), + versionKey: z.string().optional().nullable(), + parentProtocol: z.string().optional().nullable(), +}); + diff --git a/defi/api-tests/src/volumes/summaryDexs.test.ts b/defi/api-tests/src/volumes/summaryDexs.test.ts new file mode 100644 index 0000000000..2eb97fe2c5 --- /dev/null +++ b/defi/api-tests/src/volumes/summaryDexs.test.ts @@ -0,0 +1,231 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { VolumeSummaryResponse, isVolumeSummaryResponse } from './types'; +import { volumeSummaryResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, + expectFreshData, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.VOLUMES.BASE_URL); + +describe('Volumes API - Summary DEXs', () => { + // Test with popular DEX protocols + const testProtocols = ['uniswap', 'pancakeswap', 'curve-dex']; + + const responses: Record> = {}; + + beforeAll(async () => { + const results = await Promise.all( + testProtocols.map((protocol) => + apiClient.get(endpoints.VOLUMES.SUMMARY_DEXS(protocol)) + ) + ); + + testProtocols.forEach((protocol, index) => { + responses[protocol] = results[index]; + }); + }, 30000); + + describe.each(testProtocols)('Protocol: %s', (protocol) => { + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(responses[protocol]); + expect(isVolumeSummaryResponse(responses[protocol].data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = volumeSummaryResponseSchema.safeParse(responses[protocol].data); + if (!result.success) { + console.log('Validation errors:', JSON.stringify(result.error.issues.slice(0, 10), null, 2)); + } + expect(result.success).toBe(true); + }); + + it('should have required fields', () => { + const data = responses[protocol].data; + expect(data).toHaveProperty('id'); + expect(data).toHaveProperty('name'); + expect(data).toHaveProperty('chains'); + expect(typeof data.id).toBe('string'); + expect(typeof data.name).toBe('string'); + expect(Array.isArray(data.chains)).toBe(true); + }); + }); + + describe('Data Quality Validation', () => { + it('should have non-empty protocol name', () => { + expect(responses[protocol].data.name.length).toBeGreaterThan(0); + }); + + it('should have at least one chain', () => { + expect(responses[protocol].data.chains.length).toBeGreaterThan(0); + }); + + it('should have valid chain names', () => { + responses[protocol].data.chains.forEach((chain) => { + expect(typeof chain).toBe('string'); + expect(chain.length).toBeGreaterThan(0); + }); + }); + + it('should have valid volume metrics when present', () => { + const data = responses[protocol].data; + + if (data.total24h !== null && data.total24h !== undefined) { + expectValidNumber(data.total24h); + expectNonNegativeNumber(data.total24h); + expect(data.total24h).toBeGreaterThan(0); + } + + if (data.total7d !== null && data.total7d !== undefined) { + expectValidNumber(data.total7d); + expectNonNegativeNumber(data.total7d); + } + + if (data.total30d !== null && data.total30d !== undefined) { + expectValidNumber(data.total30d); + expectNonNegativeNumber(data.total30d); + } + }); + + it('should have valid change percentages when present', () => { + const data = responses[protocol].data; + + if (data.change_1d !== null && data.change_1d !== undefined) { + expectValidNumber(data.change_1d); + expect(isNaN(data.change_1d)).toBe(false); + expect(isFinite(data.change_1d)).toBe(true); + } + + if (data.change_7d !== null && data.change_7d !== undefined) { + expectValidNumber(data.change_7d); + expect(isNaN(data.change_7d)).toBe(false); + expect(isFinite(data.change_7d)).toBe(true); + } + }); + }); + + describe('Chart Data Validation', () => { + it('should have chart data when present', () => { + const data = responses[protocol].data; + + if (data.totalDataChart && data.totalDataChart.length > 0) { + expect(Array.isArray(data.totalDataChart)).toBe(true); + expect(data.totalDataChart.length).toBeGreaterThan(10); + + // Check a few data points + data.totalDataChart.slice(0, 20).forEach((point) => { + expect(Array.isArray(point)).toBe(true); + expect(point.length).toBe(2); + + // Timestamp + expectValidNumber(point[0]); + expect(point[0]).toBeGreaterThan(1400000000); // After May 2014 + + // Value + const value = typeof point[1] === 'string' ? Number(point[1]) : point[1]; + expectValidNumber(value); + expectNonNegativeNumber(value); + }); + } + }); + + it('should have fresh chart data when present', () => { + const data = responses[protocol].data; + + if (data.totalDataChart && data.totalDataChart.length > 0) { + const timestamps = data.totalDataChart.map((point) => point[0]); + expectFreshData(timestamps, 86400 * 3); // 3 days + } + }); + + it('should have chronologically ordered chart data', () => { + const data = responses[protocol].data; + + if (data.totalDataChart && data.totalDataChart.length > 1) { + const timestamps = data.totalDataChart.map((point) => point[0]); + + for (let i = 1; i < timestamps.length; i++) { + expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]); + } + } + }); + }); + + describe('Metadata Validation', () => { + it('should have valid optional metadata when present', () => { + const data = responses[protocol].data; + + if (data.logo) { + expect(typeof data.logo).toBe('string'); + expect(data.logo.length).toBeGreaterThan(0); + } + + if (data.url) { + expect(typeof data.url).toBe('string'); + expect(data.url.length).toBeGreaterThan(0); + } + + if (data.description) { + expect(typeof data.description).toBe('string'); + expect(data.description.length).toBeGreaterThan(0); + } + + if (data.twitter) { + expect(typeof data.twitter).toBe('string'); + } + }); + + it('should have valid category when present', () => { + const data = responses[protocol].data; + + if (data.category) { + expect(typeof data.category).toBe('string'); + expect(data.category.length).toBeGreaterThan(0); + } + }); + }); + }); + + describe('Cross-Protocol Comparison', () => { + it('should have different protocol IDs', () => { + const ids = testProtocols.map((protocol) => responses[protocol].data.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(testProtocols.length); + }); + + it('should have different protocol names', () => { + const names = testProtocols.map((protocol) => responses[protocol].data.name); + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(testProtocols.length); + }); + + it('should have varying volume metrics', () => { + const volumes = testProtocols + .map((protocol) => responses[protocol].data.total24h) + .filter((vol) => vol !== null && vol !== undefined); + + if (volumes.length > 1) { + const uniqueVolumes = new Set(volumes.map((v) => Number(v))); + expect(uniqueVolumes.size).toBeGreaterThan(1); + } + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent protocol gracefully', async () => { + const response = await apiClient.get( + endpoints.VOLUMES.SUMMARY_DEXS('nonexistentprotocol123456') + ); + + // Should return 404 or similar error status + expect(response.status).toBeGreaterThanOrEqual(400); + }); + }); +}); + diff --git a/defi/api-tests/src/volumes/summaryOptions.test.ts b/defi/api-tests/src/volumes/summaryOptions.test.ts new file mode 100644 index 0000000000..164a9c245d --- /dev/null +++ b/defi/api-tests/src/volumes/summaryOptions.test.ts @@ -0,0 +1,223 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { VolumeSummaryResponse, isVolumeSummaryResponse } from './types'; +import { volumeSummaryResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, + expectFreshData, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.VOLUMES.BASE_URL); + +describe('Volumes API - Summary Options', () => { + // Test with known options protocols + const testProtocols = ['lyra', 'hegic', 'premia']; + + const responses: Record> = {}; + + beforeAll(async () => { + const results = await Promise.all( + testProtocols.map((protocol) => + apiClient.get(endpoints.VOLUMES.SUMMARY_OPTIONS(protocol)) + ) + ); + + testProtocols.forEach((protocol, index) => { + responses[protocol] = results[index]; + }); + }, 30000); + + describe.each(testProtocols)('Protocol: %s', (protocol) => { + // Check if the response was successful before running tests + const isSuccessful = responses[protocol]?.status >= 200 && responses[protocol]?.status < 300; + const skipIfFailed = isSuccessful ? describe : describe.skip; + + skipIfFailed('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(responses[protocol]); + expect(isVolumeSummaryResponse(responses[protocol].data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = volumeSummaryResponseSchema.safeParse(responses[protocol].data); + if (!result.success) { + console.log('Validation errors:', JSON.stringify(result.error.issues.slice(0, 10), null, 2)); + } + expect(result.success).toBe(true); + }); + + it('should have required fields', () => { + const data = responses[protocol].data; + expect(data).toHaveProperty('id'); + expect(data).toHaveProperty('name'); + expect(data).toHaveProperty('chains'); + expect(typeof data.id).toBe('string'); + expect(typeof data.name).toBe('string'); + expect(Array.isArray(data.chains)).toBe(true); + }); + }); + + skipIfFailed('Data Quality Validation', () => { + it('should have non-empty protocol name', () => { + expect(responses[protocol].data.name.length).toBeGreaterThan(0); + }); + + it('should have at least one chain', () => { + expect(responses[protocol].data.chains.length).toBeGreaterThan(0); + }); + + it('should have valid chain names', () => { + responses[protocol].data.chains.forEach((chain) => { + expect(typeof chain).toBe('string'); + expect(chain.length).toBeGreaterThan(0); + }); + }); + + it('should have valid volume metrics when present', () => { + const data = responses[protocol].data; + + if (data.total24h !== null && data.total24h !== undefined) { + expectValidNumber(data.total24h); + expectNonNegativeNumber(data.total24h); + } + + if (data.total7d !== null && data.total7d !== undefined) { + expectValidNumber(data.total7d); + expectNonNegativeNumber(data.total7d); + } + + if (data.total30d !== null && data.total30d !== undefined) { + expectValidNumber(data.total30d); + expectNonNegativeNumber(data.total30d); + } + }); + + it('should have valid change percentages when present', () => { + const data = responses[protocol].data; + + if (data.change_1d !== null && data.change_1d !== undefined) { + expectValidNumber(data.change_1d); + expect(isNaN(data.change_1d)).toBe(false); + expect(isFinite(data.change_1d)).toBe(true); + } + + if (data.change_7d !== null && data.change_7d !== undefined) { + expectValidNumber(data.change_7d); + expect(isNaN(data.change_7d)).toBe(false); + expect(isFinite(data.change_7d)).toBe(true); + } + }); + }); + + skipIfFailed('Chart Data Validation', () => { + it('should have chart data when present', () => { + const data = responses[protocol].data; + + if (data.totalDataChart && data.totalDataChart.length > 0) { + expect(Array.isArray(data.totalDataChart)).toBe(true); + expect(data.totalDataChart.length).toBeGreaterThan(0); + + // Check a few data points + data.totalDataChart.slice(0, 20).forEach((point) => { + expect(Array.isArray(point)).toBe(true); + expect(point.length).toBe(2); + + // Timestamp + expectValidNumber(point[0]); + expect(point[0]).toBeGreaterThan(1400000000); // After May 2014 + + // Value + const value = typeof point[1] === 'string' ? Number(point[1]) : point[1]; + expectValidNumber(value); + expectNonNegativeNumber(value); + }); + } + }); + + it('should have fresh chart data when present', () => { + const data = responses[protocol].data; + + if (data.totalDataChart && data.totalDataChart.length > 0) { + const timestamps = data.totalDataChart.map((point) => point[0]); + expectFreshData(timestamps, 86400 * 5); // 5 days (options may update less frequently) + } + }); + + it('should have chronologically ordered chart data', () => { + const data = responses[protocol].data; + + if (data.totalDataChart && data.totalDataChart.length > 1) { + const timestamps = data.totalDataChart.map((point) => point[0]); + + for (let i = 1; i < timestamps.length; i++) { + expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]); + } + } + }); + }); + + skipIfFailed('Metadata Validation', () => { + it('should have valid optional metadata when present', () => { + const data = responses[protocol].data; + + if (data.logo) { + expect(typeof data.logo).toBe('string'); + expect(data.logo.length).toBeGreaterThan(0); + } + + if (data.url) { + expect(typeof data.url).toBe('string'); + expect(data.url.length).toBeGreaterThan(0); + } + + if (data.description) { + expect(typeof data.description).toBe('string'); + expect(data.description.length).toBeGreaterThan(0); + } + + if (data.twitter) { + expect(typeof data.twitter).toBe('string'); + } + }); + + it('should have valid category when present', () => { + const data = responses[protocol].data; + + if (data.category) { + expect(typeof data.category).toBe('string'); + expect(data.category.length).toBeGreaterThan(0); + } + }); + }); + }); + + describe('Cross-Protocol Comparison', () => { + it('should have different protocol IDs', () => { + const ids = testProtocols.map((protocol) => responses[protocol].data.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(testProtocols.length); + }); + + it('should have different protocol names', () => { + const names = testProtocols.map((protocol) => responses[protocol].data.name); + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(testProtocols.length); + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent protocol gracefully', async () => { + const response = await apiClient.get( + endpoints.VOLUMES.SUMMARY_OPTIONS('nonexistentprotocol123456') + ); + + // Should return 404 or similar error status + expect(response.status).toBeGreaterThanOrEqual(400); + }); + }); +}); + diff --git a/defi/api-tests/src/volumes/types.ts b/defi/api-tests/src/volumes/types.ts new file mode 100644 index 0000000000..eb2066abb0 --- /dev/null +++ b/defi/api-tests/src/volumes/types.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { + volumeProtocolSchema, + dataChartPointSchema, + volumeOverviewResponseSchema, + volumeSummaryResponseSchema, +} from './schemas'; + +// Inferred types +export type VolumeProtocol = z.infer; +export type DataChartPoint = z.infer; +export type VolumeOverviewResponse = z.infer; +export type VolumeSummaryResponse = z.infer; + +// Type guards +export function isVolumeOverviewResponse(data: any): data is VolumeOverviewResponse { + return ( + data && + typeof data === 'object' && + Array.isArray(data.protocols) && + Array.isArray(data.totalDataChart) + ); +} + +export function isVolumeSummaryResponse(data: any): data is VolumeSummaryResponse { + return ( + data && + typeof data === 'object' && + typeof data.id === 'string' && + typeof data.name === 'string' && + Array.isArray(data.chains) + ); +} + diff --git a/defi/api-tests/src/yields/chart.test.ts b/defi/api-tests/src/yields/chart.test.ts new file mode 100644 index 0000000000..7e955bb39d --- /dev/null +++ b/defi/api-tests/src/yields/chart.test.ts @@ -0,0 +1,146 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { ChartResponse, isChartResponse } from './types'; +import { chartResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, + expectFreshData, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.YIELDS_PRO.BASE_URL); + +describe('Yields Pro API - Chart', () => { + const testPools = [ + '747c1d2a-c668-4682-b9f9-296708a3dd90', // Lido stETH + ]; + + testPools.forEach((poolId) => { + describe(`Pool: ${poolId}`, () => { + let chartResponse: ApiResponse; + + beforeAll(async () => { + chartResponse = await apiClient.get(endpoints.YIELDS_PRO.CHART(poolId)); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(chartResponse); + expect(chartResponse.data).toHaveProperty('status'); + expect(chartResponse.data).toHaveProperty('data'); + expect(chartResponse.data.status).toBe('success'); + expect(Array.isArray(chartResponse.data.data)).toBe(true); + expect(isChartResponse(chartResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate(chartResponse.data, chartResponseSchema, `Chart-${poolId}`); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should return a non-empty array', () => { + expect(chartResponse.data.data.length).toBeGreaterThan(0); + }); + + it('should have sufficient historical data', () => { + expect(chartResponse.data.data.length).toBeGreaterThan(10); + }); + }); + + describe('Chart Data Point Validation', () => { + it('should have required fields in all data points', () => { + chartResponse.data.data.slice(0, 20).forEach((point) => { + expect(point).toHaveProperty('timestamp'); + expect(typeof point.timestamp).toBe('string'); + }); + }); + + it('should have valid timestamp format', () => { + chartResponse.data.data.slice(0, 20).forEach((point) => { + expect(() => new Date(point.timestamp)).not.toThrow(); + const date = new Date(point.timestamp); + expect(date.getTime()).toBeGreaterThan(0); + }); + }); + + it('should have valid TVL values when present', () => { + const pointsWithTvl = chartResponse.data.data + .filter((point) => point.tvlUsd !== undefined) + .slice(0, 20); + + if (pointsWithTvl.length > 0) { + pointsWithTvl.forEach((point) => { + expectValidNumber(point.tvlUsd!); + expectNonNegativeNumber(point.tvlUsd!); + }); + } + }); + + it('should have valid APY values when present', () => { + const pointsWithApy = chartResponse.data.data + .filter((point) => point.apy !== null && point.apy !== undefined) + .slice(0, 20); + + if (pointsWithApy.length > 0) { + pointsWithApy.forEach((point) => { + expectValidNumber(point.apy!); + expect(point.apy!).toBeGreaterThan(-100); + expect(point.apy!).toBeLessThan(10000); + }); + } + }); + + it('should have chronologically ordered timestamps', () => { + const timestamps = chartResponse.data.data.map((point) => + new Date(point.timestamp).getTime() + ); + + for (let i = 0; i < timestamps.length - 1; i++) { + expect(timestamps[i]).toBeLessThanOrEqual(timestamps[i + 1]); + } + }); + }); + + describe('Data Quality Validation', () => { + it('should have fresh data', () => { + const timestamps = chartResponse.data.data.map((point) => + Math.floor(new Date(point.timestamp).getTime() / 1000) + ); + expectFreshData(timestamps, 86400 * 7); // 7 days + }); + + it('should have reasonable data coverage', () => { + const firstDate = new Date(chartResponse.data.data[0].timestamp); + const lastDate = new Date( + chartResponse.data.data[chartResponse.data.data.length - 1].timestamp + ); + const daysDiff = + (lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24); + + expect(daysDiff).toBeGreaterThan(7); // At least a week of data + }); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle non-existent pool gracefully', async () => { + const response = await apiClient.get( + endpoints.YIELDS_PRO.CHART('non-existent-pool-xyz-123') + ); + + if (response.status === 200) { + expect(response.data).toBeDefined(); + } else { + expect(response.status).toBeGreaterThanOrEqual(400); + } + }); + }); +}); + diff --git a/defi/api-tests/src/yields/chartLendBorrow.test.ts b/defi/api-tests/src/yields/chartLendBorrow.test.ts new file mode 100644 index 0000000000..d3e66b14d1 --- /dev/null +++ b/defi/api-tests/src/yields/chartLendBorrow.test.ts @@ -0,0 +1,192 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { + ChartLendBorrowResponse, + isChartLendBorrowResponse, + PoolsBorrowResponse, +} from './types'; +import { chartLendBorrowResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, + expectFreshData, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.YIELDS_PRO.BASE_URL); + +describe('Yields Pro API - Chart Lend Borrow', () => { + let testPoolId: string; + let chartLendBorrowResponse: ApiResponse; + + beforeAll(async () => { + // Get a pool ID from the borrow pools endpoint + const poolsResponse = await apiClient.get( + endpoints.YIELDS_PRO.POOLS_BORROW + ); + + if (poolsResponse.data && (poolsResponse.data as any).data && (poolsResponse.data as any).data[0]) { + testPoolId = (poolsResponse.data as any).data[0].pool; + } else { + throw new Error('Could not fetch test pool ID'); + } + + chartLendBorrowResponse = await apiClient.get( + endpoints.YIELDS_PRO.CHART_LEND_BORROW(testPoolId) + ); + }, 60000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(chartLendBorrowResponse); + expect(chartLendBorrowResponse.data).toHaveProperty('status'); + expect(chartLendBorrowResponse.data).toHaveProperty('data'); + expect(chartLendBorrowResponse.data.status).toBe('success'); + expect(Array.isArray(chartLendBorrowResponse.data.data)).toBe(true); + expect(isChartLendBorrowResponse(chartLendBorrowResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate( + chartLendBorrowResponse.data, + chartLendBorrowResponseSchema, + `ChartLendBorrow-${testPoolId}` + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should return a non-empty array', () => { + expect(chartLendBorrowResponse.data.data.length).toBeGreaterThan(0); + }); + + it('should have sufficient historical data', () => { + expect(chartLendBorrowResponse.data.data.length).toBeGreaterThan(5); + }); + }); + + describe('Chart Data Point Validation', () => { + it('should have required fields in all data points', () => { + chartLendBorrowResponse.data.data.slice(0, 20).forEach((point) => { + expect(point).toHaveProperty('timestamp'); + expect(typeof point.timestamp).toBe('string'); + }); + }); + + it('should have valid timestamp format', () => { + chartLendBorrowResponse.data.data.slice(0, 20).forEach((point) => { + expect(() => new Date(point.timestamp)).not.toThrow(); + const date = new Date(point.timestamp); + expect(date.getTime()).toBeGreaterThan(0); + }); + }); + + it('should have valid supply and borrow values when present', () => { + chartLendBorrowResponse.data.data.slice(0, 20).forEach((point) => { + if (point.totalSupplyUsd !== undefined) { + expectValidNumber(point.totalSupplyUsd); + expectNonNegativeNumber(point.totalSupplyUsd); + } + + if (point.totalBorrowUsd !== undefined) { + expectValidNumber(point.totalBorrowUsd); + expectNonNegativeNumber(point.totalBorrowUsd); + } + }); + }); + + it('should have valid APY values when present', () => { + const pointsWithApy = chartLendBorrowResponse.data.data + .filter((point) => point.apyBase !== null && point.apyBase !== undefined) + .slice(0, 20); + + if (pointsWithApy.length > 0) { + pointsWithApy.forEach((point) => { + expectValidNumber(point.apyBase!); + expect(point.apyBase!).toBeGreaterThan(-100); + expect(point.apyBase!).toBeLessThan(10000); + }); + } + }); + + it('should have valid borrow APY values when present', () => { + const pointsWithBorrowApy = chartLendBorrowResponse.data.data + .filter((point) => point.apyBorrow !== null && point.apyBorrow !== undefined) + .slice(0, 20); + + if (pointsWithBorrowApy.length > 0) { + pointsWithBorrowApy.forEach((point) => { + expectValidNumber(point.apyBorrow!); + expect(point.apyBorrow!).toBeGreaterThan(-100); + expect(point.apyBorrow!).toBeLessThan(10000); + }); + } + }); + + it('should have valid LTV values when present', () => { + const pointsWithLtv = chartLendBorrowResponse.data.data + .filter((point) => point.ltv !== null && point.ltv !== undefined) + .slice(0, 20); + + if (pointsWithLtv.length > 0) { + pointsWithLtv.forEach((point) => { + expectValidNumber(point.ltv!); + expect(point.ltv!).toBeGreaterThan(0); + expect(point.ltv!).toBeLessThanOrEqual(1); + }); + } + }); + + it('should have chronologically ordered timestamps', () => { + const timestamps = chartLendBorrowResponse.data.data.map((point) => + new Date(point.timestamp).getTime() + ); + + for (let i = 0; i < timestamps.length - 1; i++) { + expect(timestamps[i]).toBeLessThanOrEqual(timestamps[i + 1]); + } + }); + }); + + describe('Data Quality Validation', () => { + it('should have fresh data', () => { + const timestamps = chartLendBorrowResponse.data.data.map((point) => + Math.floor(new Date(point.timestamp).getTime() / 1000) + ); + expectFreshData(timestamps, 86400 * 7); // 7 days + }); + + it('should have borrow not exceeding supply', () => { + const pointsWithBoth = chartLendBorrowResponse.data.data.filter( + (point) => + point.totalSupplyUsd !== undefined && + point.totalBorrowUsd !== undefined && + point.totalSupplyUsd > 0 && + point.totalBorrowUsd > 0 + ); + + if (pointsWithBoth.length > 0) { + pointsWithBoth.slice(0, 20).forEach((point) => { + expect(point.totalBorrowUsd!).toBeLessThanOrEqual(point.totalSupplyUsd! * 1.1); + }); + } + }); + + it('should have reasonable data coverage', () => { + if (chartLendBorrowResponse.data.data.length > 1) { + const firstDate = new Date(chartLendBorrowResponse.data.data[0].timestamp); + const lastDate = new Date( + chartLendBorrowResponse.data.data[chartLendBorrowResponse.data.data.length - 1].timestamp + ); + const daysDiff = (lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24); + + expect(daysDiff).toBeGreaterThan(0); + } + }); + }); +}); + diff --git a/defi/api-tests/src/yields/lsdRates.test.ts b/defi/api-tests/src/yields/lsdRates.test.ts new file mode 100644 index 0000000000..791e0c108a --- /dev/null +++ b/defi/api-tests/src/yields/lsdRates.test.ts @@ -0,0 +1,167 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { LsdRatesResponse, isLsdRatesResponse } from './types'; +import { lsdRatesResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectArrayResponse, + expectValidNumber, + expectNonNegativeNumber, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.YIELDS_PRO.BASE_URL); + +describe('Yields Pro API - LSD Rates', () => { + let lsdRatesResponse: ApiResponse; + + beforeAll(async () => { + lsdRatesResponse = await apiClient.get(endpoints.YIELDS_PRO.LSD_RATES); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(lsdRatesResponse); + expectArrayResponse(lsdRatesResponse); + expect(isLsdRatesResponse(lsdRatesResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate(lsdRatesResponse.data, lsdRatesResponseSchema, 'LsdRates'); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should return a non-empty array', () => { + expect(lsdRatesResponse.data.length).toBeGreaterThan(0); + }); + + it('should have minimum expected LSD rates', () => { + expect(lsdRatesResponse.data.length).toBeGreaterThan(5); + }); + }); + + describe('LSD Rate Item Validation', () => { + it('should have at least one identifying field', () => { + lsdRatesResponse.data.slice(0, 20).forEach((rate) => { + const hasIdentifier = rate.address || rate.name || rate.symbol || rate.pool; + expect(hasIdentifier).toBeTruthy(); + }); + }); + + it('should have valid timestamp format when present', () => { + const ratesWithTimestamp = lsdRatesResponse.data + .filter((rate) => rate.timestamp !== undefined) + .slice(0, 20); + + if (ratesWithTimestamp.length > 0) { + ratesWithTimestamp.forEach((rate) => { + expect(() => new Date(rate.timestamp!)).not.toThrow(); + const date = new Date(rate.timestamp!); + expect(date.getTime()).toBeGreaterThan(0); + }); + } + }); + + it('should have valid APY values when present', () => { + const ratesWithApy = lsdRatesResponse.data + .filter((rate) => rate.apy !== null && rate.apy !== undefined) + .slice(0, 20); + + if (ratesWithApy.length > 0) { + ratesWithApy.forEach((rate) => { + expectValidNumber(rate.apy!); + expect(rate.apy!).toBeGreaterThan(-100); + expect(rate.apy!).toBeLessThan(1000); + }); + } + }); + + it('should have valid TVL values when present', () => { + const ratesWithTvl = lsdRatesResponse.data + .filter((rate) => rate.tvl !== null && rate.tvl !== undefined) + .slice(0, 20); + + if (ratesWithTvl.length > 0) { + ratesWithTvl.forEach((rate) => { + expectValidNumber(rate.tvl!); + expectNonNegativeNumber(rate.tvl!); + }); + } + }); + + it('should have valid pool IDs when present', () => { + const ratesWithPool = lsdRatesResponse.data + .filter((rate) => rate.pool !== undefined) + .slice(0, 20); + + if (ratesWithPool.length > 0) { + ratesWithPool.forEach((rate) => { + expect(rate.pool).toMatch(/^[a-zA-Z0-9\-]+$/); + }); + } + }); + + it('should have valid optional string fields when present', () => { + lsdRatesResponse.data.slice(0, 20).forEach((rate) => { + if (rate.underlying !== undefined) { + expect(typeof rate.underlying).toBe('string'); + } + if (rate.symbol !== undefined) { + expect(typeof rate.symbol).toBe('string'); + } + if (rate.project !== undefined) { + expect(typeof rate.project).toBe('string'); + } + if (rate.chain !== undefined) { + expect(typeof rate.chain).toBe('string'); + } + }); + }); + }); + + describe('Data Quality Validation', () => { + it('should have unique identifiers', () => { + const identifiers = lsdRatesResponse.data.map((rate) => + rate.pool || rate.address || rate.symbol || rate.name + ); + const uniqueIds = new Set(identifiers.filter(id => id !== undefined)); + expect(uniqueIds.size).toBeGreaterThan(0); + }); + + it('should have rates with recent timestamps when present', () => { + const ratesWithTimestamp = lsdRatesResponse.data.filter( + (rate) => rate.timestamp !== undefined + ); + + if (ratesWithTimestamp.length > 0) { + const recentTimestamps = ratesWithTimestamp.filter((rate) => { + const date = new Date(rate.timestamp!); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + return diff < 86400000 * 7; // Within 7 days + }); + + expect(recentTimestamps.length).toBeGreaterThan(0); + } + }); + + it('should have rates with market rate data', () => { + const ratesWithMarketRate = lsdRatesResponse.data.filter( + (rate) => rate.marketRate !== undefined + ); + expect(ratesWithMarketRate.length).toBeGreaterThan(0); + }); + + it('should have rates with symbol or name', () => { + const ratesWithLabel = lsdRatesResponse.data.filter( + (rate) => rate.symbol !== undefined || rate.name !== undefined + ); + expect(ratesWithLabel.length).toBeGreaterThan(0); + }); + }); +}); + diff --git a/defi/api-tests/src/yields/perps.test.ts b/defi/api-tests/src/yields/perps.test.ts new file mode 100644 index 0000000000..c5241c7492 --- /dev/null +++ b/defi/api-tests/src/yields/perps.test.ts @@ -0,0 +1,153 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { PerpsResponse, isPerpsResponse } from './types'; +import { perpsResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.YIELDS_PRO.BASE_URL); + +describe('Yields Pro API - Perps', () => { + let perpsResponse: ApiResponse; + + beforeAll(async () => { + perpsResponse = await apiClient.get(endpoints.YIELDS_PRO.PERPS); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(perpsResponse); + expect(perpsResponse.data).toHaveProperty('status'); + expect(perpsResponse.data).toHaveProperty('data'); + expect(perpsResponse.data.status).toBe('success'); + expect(Array.isArray(perpsResponse.data.data)).toBe(true); + expect(isPerpsResponse(perpsResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate(perpsResponse.data, perpsResponseSchema, 'Perps'); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should return a non-empty array', () => { + expect(perpsResponse.data.data.length).toBeGreaterThan(0); + }); + + it('should have minimum expected perps', () => { + expect(perpsResponse.data.data.length).toBeGreaterThan(10); + }); + }); + + describe('Perp Item Validation', () => { + it('should have required fields in all perps', () => { + perpsResponse.data.data.slice(0, 20).forEach((perp) => { + expect(perp).toHaveProperty('perp_id'); + expect(perp).toHaveProperty('timestamp'); + expect(perp).toHaveProperty('marketplace'); + expect(perp).toHaveProperty('market'); + expect(perp).toHaveProperty('baseAsset'); + + expect(typeof perp.perp_id).toBe('string'); + expect(typeof perp.timestamp).toBe('string'); + expect(typeof perp.marketplace).toBe('string'); + expect(typeof perp.market).toBe('string'); + expect(typeof perp.baseAsset).toBe('string'); + + if (perp.quoteAsset !== undefined) { + expect(typeof perp.quoteAsset).toBe('string'); + } + }); + }); + + it('should have valid timestamp format', () => { + perpsResponse.data.data.slice(0, 20).forEach((perp) => { + expect(() => new Date(perp.timestamp)).not.toThrow(); + const date = new Date(perp.timestamp); + expect(date.getTime()).toBeGreaterThan(0); + }); + }); + + it('should have valid funding rate when present', () => { + const perpsWithFunding = perpsResponse.data.data + .filter((perp) => perp.fundingRate !== null && perp.fundingRate !== undefined) + .slice(0, 20); + + if (perpsWithFunding.length > 0) { + perpsWithFunding.forEach((perp) => { + expectValidNumber(perp.fundingRate!); + expect(perp.fundingRate!).toBeGreaterThan(-100); + expect(perp.fundingRate!).toBeLessThan(100); + }); + } + }); + + it('should have valid open interest when present', () => { + const perpsWithOI = perpsResponse.data.data + .filter((perp) => perp.openInterest !== null && perp.openInterest !== undefined) + .slice(0, 20); + + if (perpsWithOI.length > 0) { + perpsWithOI.forEach((perp) => { + expectValidNumber(perp.openInterest!); + expect(perp.openInterest!).toBeGreaterThan(0); + }); + } + }); + + it('should have valid volume when present', () => { + const perpsWithVolume = perpsResponse.data.data + .filter((perp) => perp.volume !== null && perp.volume !== undefined) + .slice(0, 20); + + if (perpsWithVolume.length > 0) { + perpsWithVolume.forEach((perp) => { + expectValidNumber(perp.volume!); + expect(perp.volume!).toBeGreaterThan(0); + }); + } + }); + + it('should have valid perp IDs', () => { + perpsResponse.data.data.slice(0, 20).forEach((perp) => { + expect(perp.perp_id).toMatch(/^[a-zA-Z0-9\-]+$/); + }); + }); + }); + + describe('Data Quality Validation', () => { + it('should have perps from multiple marketplaces', () => { + const marketplaces = new Set(perpsResponse.data.data.map((perp) => perp.marketplace)); + expect(marketplaces.size).toBeGreaterThan(1); + }); + + it('should have perps for multiple markets', () => { + const markets = new Set(perpsResponse.data.data.map((perp) => perp.market)); + expect(markets.size).toBeGreaterThan(5); + }); + + it('should have unique perp IDs', () => { + const perpIds = perpsResponse.data.data.map((perp) => perp.perp_id); + const uniqueIds = new Set(perpIds); + expect(uniqueIds.size).toBe(perpIds.length); + }); + + it('should have perps with recent timestamps', () => { + const recentTimestamps = perpsResponse.data.data.filter((perp) => { + const date = new Date(perp.timestamp); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + return diff < 86400000 * 7; // Within 7 days + }); + + expect(recentTimestamps.length).toBeGreaterThan(0); + }); + }); +}); + diff --git a/defi/api-tests/src/yields/pools.test.ts b/defi/api-tests/src/yields/pools.test.ts new file mode 100644 index 0000000000..d94934b9be --- /dev/null +++ b/defi/api-tests/src/yields/pools.test.ts @@ -0,0 +1,167 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { PoolsResponse, isPoolsResponse } from './types'; +import { poolsResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.YIELDS_PRO.BASE_URL); + +describe('Yields Pro API - Pools', () => { + let poolsResponse: ApiResponse; + + beforeAll(async () => { + poolsResponse = await apiClient.get(endpoints.YIELDS_PRO.POOLS); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(poolsResponse); + expect(poolsResponse.data).toHaveProperty('status'); + expect(poolsResponse.data).toHaveProperty('data'); + expect(poolsResponse.data.status).toBe('success'); + expect(Array.isArray(poolsResponse.data.data)).toBe(true); + expect(isPoolsResponse(poolsResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate(poolsResponse.data, poolsResponseSchema, 'Pools'); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should return a non-empty array', () => { + expect(poolsResponse.data.data.length).toBeGreaterThan(0); + }); + + it('should have minimum expected pools', () => { + expect(poolsResponse.data.data.length).toBeGreaterThan(100); + }); + }); + + describe('Pool Item Validation', () => { + it('should have required fields in all pools', () => { + poolsResponse.data.data.slice(0, 20).forEach((pool) => { + expect(pool).toHaveProperty('chain'); + expect(pool).toHaveProperty('project'); + expect(pool).toHaveProperty('symbol'); + expect(pool).toHaveProperty('tvlUsd'); + expect(pool).toHaveProperty('pool'); + + expect(typeof pool.chain).toBe('string'); + expect(typeof pool.project).toBe('string'); + expect(typeof pool.symbol).toBe('string'); + expect(typeof pool.pool).toBe('string'); + }); + }); + + it('should have valid TVL values', () => { + poolsResponse.data.data.slice(0, 50).forEach((pool) => { + expectValidNumber(pool.tvlUsd); + expectNonNegativeNumber(pool.tvlUsd); + }); + }); + + it('should have valid APY values when present', () => { + const poolsWithApy = poolsResponse.data.data + .filter((pool) => pool.apy !== null && pool.apy !== undefined) + .slice(0, 50); + + expect(poolsWithApy.length).toBeGreaterThan(0); + + poolsWithApy.forEach((pool) => { + expectValidNumber(pool.apy!); + expect(pool.apy!).toBeGreaterThan(-100); + expect(pool.apy!).toBeLessThan(10000); + }); + }); + + it('should have valid pool IDs', () => { + poolsResponse.data.data.slice(0, 20).forEach((pool) => { + expect(pool.pool).toMatch(/^[a-zA-Z0-9\-]+$/); + }); + }); + + it('should have valid percentage change fields when present', () => { + const poolsWithPct = poolsResponse.data.data + .filter((pool) => pool.apyPct1D !== null && pool.apyPct1D !== undefined) + .slice(0, 20); + + if (poolsWithPct.length > 0) { + poolsWithPct.forEach((pool) => { + if (pool.apyPct1D !== null && pool.apyPct1D !== undefined) { + expectValidNumber(pool.apyPct1D); + } + if (pool.apyPct7D !== null && pool.apyPct7D !== undefined) { + expectValidNumber(pool.apyPct7D); + } + if (pool.apyPct30D !== null && pool.apyPct30D !== undefined) { + expectValidNumber(pool.apyPct30D); + } + }); + } + }); + + it('should have valid reward tokens array when present', () => { + const poolsWithRewards = poolsResponse.data.data + .filter((pool) => pool.rewardTokens !== null && pool.rewardTokens !== undefined) + .slice(0, 20); + + if (poolsWithRewards.length > 0) { + poolsWithRewards.forEach((pool) => { + expect(Array.isArray(pool.rewardTokens)).toBe(true); + if (pool.rewardTokens && pool.rewardTokens.length > 0) { + pool.rewardTokens.forEach((token) => { + expect(typeof token).toBe('string'); + }); + } + }); + } + }); + }); + + describe('Data Quality Validation', () => { + it('should have pools with high TVL', () => { + const highTvlPools = poolsResponse.data.data.filter((pool) => pool.tvlUsd > 1_000_000_000); + expect(highTvlPools.length).toBeGreaterThan(0); + }); + + it('should have pools from multiple chains', () => { + const chains = new Set(poolsResponse.data.data.map((pool) => pool.chain)); + expect(chains.size).toBeGreaterThan(5); + }); + + it('should have pools from multiple projects', () => { + const projects = new Set(poolsResponse.data.data.map((pool) => pool.project)); + expect(projects.size).toBeGreaterThan(10); + }); + + it('should have unique pool IDs', () => { + const poolIds = poolsResponse.data.data.map((pool) => pool.pool); + const uniqueIds = new Set(poolIds); + expect(uniqueIds.size).toBe(poolIds.length); + }); + + it('should have pools with stablecoin flag', () => { + const stablecoinPools = poolsResponse.data.data.filter( + (pool) => pool.stablecoin === true + ); + expect(stablecoinPools.length).toBeGreaterThan(0); + }); + + it('should have pools sorted by TVL in descending order', () => { + const firstTenPools = poolsResponse.data.data.slice(0, 10); + for (let i = 0; i < firstTenPools.length - 1; i++) { + expect(firstTenPools[i].tvlUsd).toBeGreaterThanOrEqual(firstTenPools[i + 1].tvlUsd); + } + }); + }); +}); + diff --git a/defi/api-tests/src/yields/poolsBorrow.test.ts b/defi/api-tests/src/yields/poolsBorrow.test.ts new file mode 100644 index 0000000000..ce7d315b0e --- /dev/null +++ b/defi/api-tests/src/yields/poolsBorrow.test.ts @@ -0,0 +1,142 @@ +import { createApiClient } from '../../utils/config/apiClient'; +import { endpoints } from '../../utils/config/endpoints'; +import { PoolsBorrowResponse, isPoolsBorrowResponse } from './types'; +import { poolsBorrowResponseSchema } from './schemas'; +import { + expectSuccessfulResponse, + expectValidNumber, + expectNonNegativeNumber, +} from '../../utils/testHelpers'; +import { validate } from '../../utils/validation'; +import { ApiResponse } from '../../utils/config/apiClient'; + +const apiClient = createApiClient(endpoints.YIELDS_PRO.BASE_URL); + +describe('Yields Pro API - Pools Borrow', () => { + let poolsBorrowResponse: ApiResponse; + + beforeAll(async () => { + poolsBorrowResponse = await apiClient.get( + endpoints.YIELDS_PRO.POOLS_BORROW + ); + }, 30000); + + describe('Basic Response Validation', () => { + it('should return successful response with valid structure', () => { + expectSuccessfulResponse(poolsBorrowResponse); + expect(poolsBorrowResponse.data).toHaveProperty('status'); + expect(poolsBorrowResponse.data).toHaveProperty('data'); + expect(poolsBorrowResponse.data.status).toBe('success'); + expect(Array.isArray(poolsBorrowResponse.data.data)).toBe(true); + expect(isPoolsBorrowResponse(poolsBorrowResponse.data)).toBe(true); + }); + + it('should validate against Zod schema', () => { + const result = validate( + poolsBorrowResponse.data, + poolsBorrowResponseSchema, + 'PoolsBorrow' + ); + expect(result.success).toBe(true); + if (!result.success) { + console.error('Validation errors (first 5):', result.errors.slice(0, 5)); + } + }); + + it('should return a non-empty array', () => { + expect(poolsBorrowResponse.data.data.length).toBeGreaterThan(0); + }); + + it('should have minimum expected borrow pools', () => { + expect(poolsBorrowResponse.data.data.length).toBeGreaterThan(50); + }); + }); + + describe('Borrow Pool Item Validation', () => { + it('should have required fields in all pools', () => { + poolsBorrowResponse.data.data.slice(0, 20).forEach((pool) => { + expect(pool).toHaveProperty('chain'); + expect(pool).toHaveProperty('project'); + expect(pool).toHaveProperty('symbol'); + expect(pool).toHaveProperty('tvlUsd'); + expect(pool).toHaveProperty('pool'); + + expect(typeof pool.chain).toBe('string'); + expect(typeof pool.project).toBe('string'); + expect(typeof pool.symbol).toBe('string'); + expect(typeof pool.pool).toBe('string'); + }); + }); + + it('should have valid TVL and supply/borrow values', () => { + poolsBorrowResponse.data.data.slice(0, 50).forEach((pool) => { + expectValidNumber(pool.tvlUsd); + expectNonNegativeNumber(pool.tvlUsd); + + if (pool.totalSupplyUsd !== undefined) { + expectValidNumber(pool.totalSupplyUsd); + expectNonNegativeNumber(pool.totalSupplyUsd); + } + + if (pool.totalBorrowUsd !== undefined && typeof pool.totalBorrowUsd === 'number') { + expectValidNumber(pool.totalBorrowUsd); + // Note: totalBorrowUsd can be negative in some cases (e.g., debt positions) + } + }); + }); + + it('should have valid borrow APY values when present', () => { + const poolsWithBorrowApy = poolsBorrowResponse.data.data + .filter((pool) => pool.apyBorrow !== null && pool.apyBorrow !== undefined) + .slice(0, 50); + + if (poolsWithBorrowApy.length > 0) { + poolsWithBorrowApy.forEach((pool) => { + expectValidNumber(pool.apyBorrow!); + expect(pool.apyBorrow!).toBeGreaterThan(-100); + expect(pool.apyBorrow!).toBeLessThan(10000); + }); + } + }); + + it('should have valid LTV values when present', () => { + const poolsWithLtv = poolsBorrowResponse.data.data + .filter((pool) => pool.ltv !== null && pool.ltv !== undefined) + .slice(0, 20); + + if (poolsWithLtv.length > 0) { + poolsWithLtv.forEach((pool) => { + expectValidNumber(pool.ltv!); + expect(pool.ltv!).toBeGreaterThan(0); + expect(pool.ltv!).toBeLessThanOrEqual(1); + }); + } + }); + }); + + describe('Data Quality Validation', () => { + it('should have pools from multiple chains', () => { + const chains = new Set(poolsBorrowResponse.data.data.map((pool) => pool.chain)); + expect(chains.size).toBeGreaterThan(3); + }); + + it('should have pools from multiple projects', () => { + const projects = new Set(poolsBorrowResponse.data.data.map((pool) => pool.project)); + expect(projects.size).toBeGreaterThan(5); + }); + + it('should have unique pool IDs', () => { + const poolIds = poolsBorrowResponse.data.data.map((pool) => pool.pool); + const uniqueIds = new Set(poolIds); + expect(uniqueIds.size).toBe(poolIds.length); + }); + + it('should have pools with borrow rates', () => { + const poolsWithBorrow = poolsBorrowResponse.data.data.filter( + (pool) => pool.apyBaseBorrow !== null && pool.apyBaseBorrow !== undefined + ); + expect(poolsWithBorrow.length).toBeGreaterThan(0); + }); + }); +}); + diff --git a/defi/api-tests/src/yields/schemas.ts b/defi/api-tests/src/yields/schemas.ts new file mode 100644 index 0000000000..fb9e2f934a --- /dev/null +++ b/defi/api-tests/src/yields/schemas.ts @@ -0,0 +1,172 @@ +import { z } from 'zod'; + +// Common wrapper schema +export const yieldsStatusWrapperSchema = (dataSchema: T) => + z.object({ + status: z.string(), + data: dataSchema, + }); + +// Pool schema for /yields/pools +export const yieldPoolSchema = z.object({ + chain: z.string(), + project: z.string(), + symbol: z.string(), + tvlUsd: z.number(), + apyBase: z.union([z.number(), z.null()]).optional(), + apyReward: z.union([z.number(), z.null()]).optional(), + apy: z.union([z.number(), z.null()]).optional(), + rewardTokens: z.union([z.array(z.string()), z.array(z.any()), z.null()]).optional(), + pool: z.string(), + apyPct1D: z.union([z.number(), z.null()]).optional(), + apyPct7D: z.union([z.number(), z.null()]).optional(), + apyPct30D: z.union([z.number(), z.null()]).optional(), + stablecoin: z.boolean().optional(), + ilRisk: z.string().optional(), + exposure: z.string().optional(), + predictions: z.record(z.string(), z.any()).optional(), + poolMeta: z.union([z.string(), z.null()]).optional(), + mu: z.number().optional(), + sigma: z.number().optional(), + count: z.number().optional(), + outlier: z.boolean().optional(), + underlyingTokens: z.union([z.array(z.string()), z.null()]).optional(), + il7d: z.union([z.number(), z.null()]).optional(), + apyBase7d: z.union([z.number(), z.null()]).optional(), + apyMean30d: z.union([z.number(), z.null()]).optional(), + volumeUsd1d: z.union([z.number(), z.null()]).optional(), + volumeUsd7d: z.union([z.number(), z.null()]).optional(), + apyBaseInception: z.union([z.number(), z.null()]).optional(), +}); + +export const poolsResponseSchema = yieldsStatusWrapperSchema(z.array(yieldPoolSchema)); + +// Old pool schema for /yields/poolsOld +export const oldPoolSchema = z.object({ + pool: z.string(), + timestamp: z.string(), + project: z.string(), + chain: z.string(), + symbol: z.string(), + tvlUsd: z.number().optional(), + apyBase: z.union([z.number(), z.null()]).optional(), + apyReward: z.union([z.number(), z.null()]).optional(), + apy: z.union([z.number(), z.null()]).optional(), + rewardTokens: z.union([z.array(z.string()), z.null()]).optional(), + il7d: z.union([z.number(), z.null()]).optional(), + apyBase7d: z.union([z.number(), z.null()]).optional(), + apyMean30d: z.union([z.number(), z.null()]).optional(), + volumeUsd1d: z.union([z.number(), z.null()]).optional(), + volumeUsd7d: z.union([z.number(), z.null()]).optional(), + apyBaseInception: z.union([z.number(), z.null()]).optional(), + underlyingTokens: z.array(z.string()).optional(), + poolMeta: z.string().optional(), + stablecoin: z.boolean().optional(), + ilRisk: z.string().optional(), + exposure: z.string().optional(), + predictions: z.record(z.string(), z.any()).optional(), +}); + +export const poolsOldResponseSchema = yieldsStatusWrapperSchema(z.array(oldPoolSchema)); + +// Borrow pool schema for /yields/poolsBorrow +export const borrowPoolSchema = z.object({ + chain: z.string(), + project: z.string(), + symbol: z.string(), + tvlUsd: z.number(), + apyBase: z.union([z.number(), z.null()]).optional(), + apyBaseBorrow: z.union([z.number(), z.null()]).optional(), + apyRewardBorrow: z.union([z.number(), z.null()]).optional(), + apyBorrow: z.union([z.number(), z.null()]).optional(), + rewardTokens: z.union([z.array(z.string()), z.null()]).optional(), + pool: z.string(), + ltv: z.union([z.number(), z.null()]).optional(), + poolMeta: z.union([z.string(), z.null()]).optional(), + underlyingTokens: z.union([z.array(z.string()), z.null()]).optional(), + totalSupplyUsd: z.number().optional(), + totalBorrowUsd: z.union([z.number(), z.record(z.string(), z.any()), z.null()]).optional(), + debtCeilingUsd: z.union([z.number(), z.null()]).optional(), + mintedCoin: z.union([z.string(), z.null()]).optional(), + borrowable: z.union([z.boolean(), z.null()]).optional(), + borrowFactor: z.union([z.number(), z.null()]).optional(), +}); + +export const poolsBorrowResponseSchema = yieldsStatusWrapperSchema(z.array(borrowPoolSchema)); + +// Perp schema for /yields/perps +export const perpSchema = z.object({ + perp_id: z.string(), + timestamp: z.string(), + marketplace: z.string(), + market: z.string(), + baseAsset: z.string(), + quoteAsset: z.string().optional(), + fundingRate: z.union([z.number(), z.null()]).optional(), + fundingRate30dAverage: z.union([z.number(), z.null()]).optional(), + fundingRate30dSum: z.union([z.number(), z.null()]).optional(), + fundingRate7dAverage: z.union([z.number(), z.null()]).optional(), + fundingRate7dSum: z.union([z.number(), z.null()]).optional(), + fundingRatePrevious: z.union([z.number(), z.null()]).optional(), + fundingTimePrevious: z.union([z.number(), z.null()]).optional(), + indexPrice: z.union([z.number(), z.null()]).optional(), + openInterest: z.union([z.number(), z.null()]).optional(), + volume: z.union([z.number(), z.null()]).optional(), + pricePercentChange: z.union([z.number(), z.null()]).optional(), + nextFundingTime: z.union([z.string(), z.null()]).optional(), +}); + +export const perpsResponseSchema = yieldsStatusWrapperSchema(z.array(perpSchema)); + +// LSD Rates schema for /yields/lsdRates +export const lsdRateSchema = z.object({ + address: z.string().optional(), + ethPeg: z.union([z.number(), z.null()]).optional(), + expectedRate: z.union([z.number(), z.null()]).optional(), + fee: z.union([z.number(), z.null()]).optional(), + marketRate: z.union([z.number(), z.null()]).optional(), + name: z.string().optional(), + symbol: z.string().optional(), + type: z.union([z.string(), z.null()]).optional(), + timestamp: z.string().optional(), + pool: z.string().optional(), + apy: z.union([z.number(), z.null()]).optional(), + tvl: z.union([z.number(), z.null()]).optional(), + underlying: z.string().optional(), + project: z.string().optional(), + chain: z.string().optional(), +}); + +export const lsdRatesResponseSchema = z.array(lsdRateSchema); + +// Chart schema for /yields/chart/{pool} +export const chartDataPointSchema = z.object({ + timestamp: z.string(), + tvlUsd: z.number().optional(), + apy: z.union([z.number(), z.null()]).optional(), + apyBase: z.union([z.number(), z.null()]).optional(), + apyReward: z.union([z.number(), z.null()]).optional(), + il7d: z.union([z.number(), z.null()]).optional(), + apyBase7d: z.union([z.number(), z.null()]).optional(), + apyMean30d: z.union([z.number(), z.null()]).optional(), +}); + +export const chartResponseSchema = yieldsStatusWrapperSchema(z.array(chartDataPointSchema)); + +// Chart Lend Borrow schema for /yields/chartLendBorrow/{pool} +export const chartLendBorrowDataPointSchema = z.object({ + timestamp: z.string(), + totalSupplyUsd: z.number().optional(), + totalBorrowUsd: z.number().optional(), + apyBase: z.union([z.number(), z.null()]).optional(), + apyBaseBorrow: z.union([z.number(), z.null()]).optional(), + apyRewardBorrow: z.union([z.number(), z.null()]).optional(), + apyBorrow: z.union([z.number(), z.null()]).optional(), + debtCeilingUsd: z.union([z.number(), z.null()]).optional(), + ltv: z.union([z.number(), z.null()]).optional(), +}); + +export const chartLendBorrowResponseSchema = yieldsStatusWrapperSchema( + z.array(chartLendBorrowDataPointSchema) +); + diff --git a/defi/api-tests/src/yields/types.ts b/defi/api-tests/src/yields/types.ts new file mode 100644 index 0000000000..796fa6089f --- /dev/null +++ b/defi/api-tests/src/yields/types.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; +import { + yieldPoolSchema, + poolsResponseSchema, + oldPoolSchema, + poolsOldResponseSchema, + borrowPoolSchema, + poolsBorrowResponseSchema, + perpSchema, + perpsResponseSchema, + lsdRateSchema, + lsdRatesResponseSchema, + chartDataPointSchema, + chartResponseSchema, + chartLendBorrowDataPointSchema, + chartLendBorrowResponseSchema, +} from './schemas'; + +// Infer types from schemas +export type YieldPool = z.infer; +export type PoolsResponse = z.infer; + +export type OldPool = z.infer; +export type PoolsOldResponse = z.infer; + +export type BorrowPool = z.infer; +export type PoolsBorrowResponse = z.infer; + +export type Perp = z.infer; +export type PerpsResponse = z.infer; + +export type LsdRate = z.infer; +export type LsdRatesResponse = z.infer; + +export type ChartDataPoint = z.infer; +export type ChartResponse = z.infer; + +export type ChartLendBorrowDataPoint = z.infer; +export type ChartLendBorrowResponse = z.infer; + +// Type guards +export function isPoolsResponse(data: unknown): data is PoolsResponse { + return poolsResponseSchema.safeParse(data).success; +} + +export function isPoolsOldResponse(data: unknown): data is PoolsOldResponse { + return poolsOldResponseSchema.safeParse(data).success; +} + +export function isPoolsBorrowResponse(data: unknown): data is PoolsBorrowResponse { + return poolsBorrowResponseSchema.safeParse(data).success; +} + +export function isPerpsResponse(data: unknown): data is PerpsResponse { + return perpsResponseSchema.safeParse(data).success; +} + +export function isLsdRatesResponse(data: unknown): data is LsdRatesResponse { + return lsdRatesResponseSchema.safeParse(data).success; +} + +export function isChartResponse(data: unknown): data is ChartResponse { + return chartResponseSchema.safeParse(data).success; +} + +export function isChartLendBorrowResponse(data: unknown): data is ChartLendBorrowResponse { + return chartLendBorrowResponseSchema.safeParse(data).success; +} + diff --git a/defi/api-tests/utils/config/apiClient.ts b/defi/api-tests/utils/config/apiClient.ts new file mode 100644 index 0000000000..10b5bec50d --- /dev/null +++ b/defi/api-tests/utils/config/apiClient.ts @@ -0,0 +1,180 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'; +import { API_CONFIG } from './endpoints'; + +export interface ApiResponse { + data: T; + status: number; + statusText: string; + headers: Record; +} + +export interface ApiErrorResponse { + message: string; + status: number; + statusText: string; + url?: string; + details?: any; +} + +export class ApiClient { + private client: AxiosInstance; + private retryCount: number; + private retryDelay: number; + + constructor(baseURL: string = '', config: Partial = {}) { + this.retryCount = API_CONFIG.retryCount; + this.retryDelay = API_CONFIG.retryDelay; + + this.client = axios.create({ + baseURL, + timeout: API_CONFIG.timeout, + headers: { + 'Content-Type': 'application/json', + ...(API_CONFIG.apiKey && { 'X-API-Key': API_CONFIG.apiKey }), + }, + // Disable keep-alive to prevent hanging connections + httpAgent: new (require('http').Agent)({ + keepAlive: false, + timeout: API_CONFIG.timeout, + }), + httpsAgent: new (require('https').Agent)({ + keepAlive: false, + timeout: API_CONFIG.timeout, + rejectUnauthorized: true, + }), + // Additional robustness settings + maxRedirects: 5, + validateStatus: (status) => status < 600, // Don't reject on any status code + ...config, + }); + + this.setupInterceptors(); + } + + private setupInterceptors(): void { + // Don't reject on error responses - we want to handle them gracefully + this.client.interceptors.response.use( + (response) => response, + (error) => { + // For HTTP error responses (4xx, 5xx), return a response-like object + if (error.response) { + return Promise.resolve(error.response); + } + // For network errors, reject + return Promise.reject(error); + } + ); + } + + private handleError(error: AxiosError): ApiErrorResponse { + if (error.response) { + // HTTP error response (4xx, 5xx) + return { + message: error.message, + status: error.response.status, + statusText: error.response.statusText, + url: error.config?.url, + details: error.response.data, + }; + } else if (error.request) { + // Network error (no response received) + let message = 'No response received from server'; + let statusText = 'Network Error'; + + if (error.code === 'ECONNABORTED') { + message = 'Request timeout - server took too long to respond'; + statusText = 'Timeout Error'; + } else if (error.code === 'ENOTFOUND') { + message = 'DNS lookup failed - unable to resolve server address'; + statusText = 'DNS Error'; + } else if (error.code === 'ECONNREFUSED') { + message = 'Connection refused - server is not accepting connections'; + statusText = 'Connection Refused'; + } else if (error.code === 'ETIMEDOUT') { + message = 'Connection timeout - unable to establish connection'; + statusText = 'Connection Timeout'; + } + + return { + message, + status: 0, + statusText, + url: error.config?.url, + details: { code: error.code, message: error.message }, + }; + } else { + // Request setup error + return { + message: error.message, + status: 0, + statusText: 'Request Setup Error', + details: error.message, + }; + } + } + + private async retryRequest( + requestFn: () => Promise>, + retries: number = this.retryCount, + attempt: number = 0 + ): Promise> { + try { + return await requestFn(); + } catch (error) { + const axiosError = error as AxiosError; + + // Only retry on network errors, not HTTP errors + const isNetworkError = !axiosError.response; + const shouldRetry = retries > 0 && isNetworkError; + + if (shouldRetry) { + // Exponential backoff: base delay * (2 ^ attempt) + // attempt 0: 1000ms, attempt 1: 2000ms, attempt 2: 4000ms + const backoffDelay = this.retryDelay * Math.pow(2, attempt); + const jitter = Math.random() * 1000; // Add random jitter (0-1000ms) + const totalDelay = backoffDelay + jitter; + + console.log(`Network error, retrying in ${Math.round(totalDelay)}ms (attempt ${attempt + 1}/${this.retryCount})...`); + + await new Promise((resolve) => setTimeout(resolve, totalDelay)); + return this.retryRequest(requestFn, retries - 1, attempt + 1); + } + + throw error; + } + } + + async get(url: string, config?: AxiosRequestConfig): Promise> { + try { + const response = await this.retryRequest(() => this.client.get(url, config)); + return this.formatResponse(response); + } catch (error) { + // Network or request setup errors - throw them + throw this.handleError(error as AxiosError); + } + } + + async post(url: string, data?: any, config?: AxiosRequestConfig): Promise> { + try { + const response = await this.retryRequest(() => this.client.post(url, data, config)); + return this.formatResponse(response); + } catch (error) { + // Network or request setup errors - throw them + throw this.handleError(error as AxiosError); + } + } + + private formatResponse(response: AxiosResponse): ApiResponse { + return { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as Record, + }; + } +} + +export function createApiClient(baseURL: string = '', config?: Partial): ApiClient { + return new ApiClient(baseURL, config); +} + diff --git a/defi/api-tests/utils/config/endpoints.ts b/defi/api-tests/utils/config/endpoints.ts new file mode 100644 index 0000000000..b6e064f1e1 --- /dev/null +++ b/defi/api-tests/utils/config/endpoints.ts @@ -0,0 +1,202 @@ +require('dotenv').config({ path: require('path').resolve(__dirname, '../../../.env') }); + +const PRO_API_KEY = process.env.PRO_API_KEY || 'api-key'; + +function isProApi(baseUrl: string): boolean { + return baseUrl.startsWith('https://pro-api.llama.fi'); +} + +function getBaseUrl(url: string, category: string): string { + if (isProApi(url)) { + if (url.includes('/api-key/')) { + return url; + } + return `${url}/${PRO_API_KEY}/${category}`; + } + return url; +} + +// Helper to get Pro API base URL with API key +function getProApiBaseUrl(): string { + const proUrl = process.env.BETA_PRO_API_URL || 'https://pro-api.llama.fi'; + if (proUrl.includes('/api-key/')) { + return proUrl; + } + return `${proUrl}/${PRO_API_KEY}`; +} + +export const BASE_URLS = { + TVL: getBaseUrl(process.env.BETA_API_URL || process.env.BASE_API_URL || 'https://api.llama.fi', 'tvl'), + COINS: getBaseUrl(process.env.BETA_COINS_URL || 'https://coins.llama.fi', 'coins'), + STABLECOINS: getBaseUrl(process.env.BETA_STABLECOINS_URL || 'https://stablecoins.llama.fi', 'stablecoins'), + YIELDS: getBaseUrl(process.env.BETA_YIELDS_URL || 'https://yields.llama.fi', 'yields'), + BRIDGES: process.env.BETA_BRIDGES_URL || 'https://bridges.llama.fi', + VOLUMES: getBaseUrl(process.env.BETA_API_URL || 'https://api.llama.fi', 'volumes'), + FEES: getBaseUrl(process.env.BETA_API_URL || 'https://api.llama.fi', 'fees'), + USERS: getProApiBaseUrl(), + MAIN_PAGE: getProApiBaseUrl(), + UNLOCKS: getProApiBaseUrl(), + YIELDS_PRO: getProApiBaseUrl(), + PERPS: getProApiBaseUrl(), + ETFS: getProApiBaseUrl(), + NARRATIVES: getProApiBaseUrl(), +}; + +const stablecoinsBaseUrl = BASE_URLS.STABLECOINS; +const isStablecoinsPro = isProApi(stablecoinsBaseUrl); + +function getEndpoint(path: string, freeOnly: boolean = false): string { + if (!isStablecoinsPro && !freeOnly) { + return ''; + } + return path; +} + +export const TVL = { + BASE_URL: BASE_URLS.TVL, + PROTOCOLS: '/protocols', + PROTOCOL: (protocol: string) => `/protocol/${protocol}`, + CHARTS: (chain?: string) => chain ? `/charts/${chain}` : '/charts', + TVL: (protocol: string) => `/tvl/${protocol}`, + HISTORICAL_CHAIN_TVL: '/v2/historicalChainTvl', + HISTORICAL_CHAIN_TVL_BY_CHAIN: (chain: string) => `/v2/historicalChainTvl/${chain}`, + CHAINS_V2: '/v2/chains', + CHAIN_ASSETS: '/chainAssets', + TOKEN_PROTOCOLS: (symbol: string) => `/tokenProtocols/${symbol}`, + INFLOWS: (protocol: string, timestamp: number) => `/inflows/${protocol}/${timestamp}`, +} as const; + +export const STABLECOINS = { + BASE_URL: stablecoinsBaseUrl, + LIST: getEndpoint('/stablecoins', true), + CHAINS: getEndpoint('/stablecoinchains', true), + PRICES: getEndpoint('/stablecoinprices', true), + CHARTS_ALL: getEndpoint('/stablecoincharts/all', true), + CHARTS_BY_CHAIN: (chain: string) => getEndpoint(`/stablecoincharts/${chain}`, true), + ASSET: (asset: string) => getEndpoint(`/stablecoin/${asset}`, true), + DOMINANCE: (chain: string) => getEndpoint(`/stablecoindominance/${chain}`, false), +} as const; + +export const YIELDS = { + BASE_URL: BASE_URLS.YIELDS, + POOLS: '/pools', + CHART: (pool: string) => `/chart/${pool}`, +} as const; + +export const COINS = { + BASE_URL: BASE_URLS.COINS, + PRICES_CURRENT: (coins: string) => `/prices/current/${coins}`, + PRICES_HISTORICAL: (timestamp: number, coins: string) => `/prices/historical/${timestamp}/${coins}`, + CHART: (coins: string) => `/chart/${coins}`, + PERCENTAGE: (coins: string) => `/percentage/${coins}`, + PRICES_FIRST: (coins: string) => `/prices/first/${coins}`, + BLOCK: (chain: string, timestamp: number) => `/block/${chain}/${timestamp}`, +} as const; + +export const VOLUMES = { + BASE_URL: BASE_URLS.VOLUMES, + OVERVIEW_DEXS: '/overview/dexs', + OVERVIEW_DEXS_CHAIN: (chain: string) => `/overview/dexs/${chain}`, + SUMMARY_DEXS: (protocol: string) => `/summary/dexs/${protocol}`, + OVERVIEW_OPTIONS: '/overview/options', + OVERVIEW_OPTIONS_CHAIN: (chain: string) => `/overview/options/${chain}`, + SUMMARY_OPTIONS: (protocol: string) => `/summary/options/${protocol}`, +} as const; + +export const FEES = { + BASE_URL: BASE_URLS.FEES, + OVERVIEW_FEES: '/overview/fees', + OVERVIEW_FEES_CHAIN: (chain: string) => `/overview/fees/${chain}`, + SUMMARY_FEES: (protocol: string) => `/summary/fees/${protocol}`, +} as const; + +export const BRIDGES = { + BASE_URL: BASE_URLS.BRIDGES, + BRIDGES: '/bridges', + BRIDGE: (id: string) => `/bridge/${id}`, + BRIDGE_VOLUME: (chain: string) => `/bridgevolume/${chain}`, + BRIDGE_DAY_STATS: (timestamp: number, chain: string) => `/bridgedaystats/${timestamp}/${chain}`, + TRANSACTIONS: (id: string) => `/transactions/${id}`, +} as const; + +export const USERS = { + BASE_URL: BASE_URLS.USERS, + ACTIVE_USERS: '/api/activeUsers', + USER_DATA: (type: string, protocolId: string) => `/api/userData/${type}/${protocolId}`, +} as const; + +export const MAIN_PAGE = { + BASE_URL: getProApiBaseUrl(), + CATEGORIES: '/api/categories', + FORKS: '/api/forks', + ORACLES: '/api/oracles', + HACKS: '/api/hacks', + RAISES: '/api/raises', + TREASURIES: '/api/treasuries', + ENTITIES: '/api/entities', +} as const; + +export const UNLOCKS = { + BASE_URL: getProApiBaseUrl(), + EMISSIONS: '/api/emissions', + EMISSION: (protocol: string) => `/api/emission/${protocol}`, +} as const; + +export const YIELDS_PRO = { + BASE_URL: getProApiBaseUrl(), + POOLS: '/yields/pools', + CHART: (pool: string) => `/yields/chart/${pool}`, + POOLS_OLD: '/yields/poolsOld', + POOLS_BORROW: '/yields/poolsBorrow', + CHART_LEND_BORROW: (pool: string) => `/yields/chartLendBorrow/${pool}`, + PERPS: '/yields/perps', + LSD_RATES: '/yields/lsdRates', +} as const; + +export const PERPS = { + BASE_URL: getProApiBaseUrl(), + OVERVIEW_OPEN_INTEREST: '/api/overview/open-interest', + OVERVIEW_DERIVATIVES: '/api/overview/derivatives', + SUMMARY_DERIVATIVES: (protocol: string) => `/api/summary/derivatives/${protocol}`, +} as const; + +export const ETFS = { + BASE_URL: getProApiBaseUrl(), + SNAPSHOT: '/etfs/snapshot', + FLOWS: '/etfs/flows', +} as const; + +export const NARRATIVES = { + BASE_URL: getProApiBaseUrl(), + FDV_PERFORMANCE: (period: string) => `/fdv/performance/${period}`, +} as const; + +export const TOKEN_LIQUIDITY = { + BASE_URL: getProApiBaseUrl(), + HISTORICAL_LIQUIDITY: (token: string) => `/api/historicalLiquidity/${token}`, +} as const; + +export const endpoints = { + TVL, + STABLECOINS, + YIELDS, + COINS, + VOLUMES, + FEES, + BRIDGES, + USERS, + MAIN_PAGE, + UNLOCKS, + YIELDS_PRO, + PERPS, + ETFS, + NARRATIVES, + TOKEN_LIQUIDITY, +} as const; + +export const API_CONFIG = { + timeout: parseInt(process.env.API_TIMEOUT || '30000', 10), + retryCount: parseInt(process.env.API_RETRY_COUNT || '3', 10), + retryDelay: parseInt(process.env.API_RETRY_DELAY || '1000', 10), + apiKey: process.env.API_KEY || '', +}; diff --git a/defi/api-tests/utils/testHelpers.ts b/defi/api-tests/utils/testHelpers.ts new file mode 100644 index 0000000000..370057b333 --- /dev/null +++ b/defi/api-tests/utils/testHelpers.ts @@ -0,0 +1,111 @@ +import { ApiResponse } from './config/apiClient'; + +export function expectSuccessfulResponse(response: ApiResponse): void { + expect(response.status).toBeGreaterThanOrEqual(200); + expect(response.status).toBeLessThan(300); + expect(response.data).toBeDefined(); +} + +export function expectStatusCode(response: ApiResponse, statusCode: number): void { + expect(response.status).toBe(statusCode); +} + +export function expectArrayResponse(response: ApiResponse): void { + expectSuccessfulResponse(response); + expect(Array.isArray(response.data)).toBe(true); +} + +export function expectObjectResponse(response: ApiResponse): void { + expectSuccessfulResponse(response); + expect(typeof response.data).toBe('object'); + expect(response.data).not.toBeNull(); +} + +export function expectNonEmptyArray(data: T[]): void { + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBeGreaterThan(0); +} + +export function expectObjectHasKeys(obj: Record, keys: string[]): void { + keys.forEach((key) => { + expect(obj).toHaveProperty(key); + }); +} + +export function expectArrayItemsHaveKeys>( + data: T[], + keys: string[] +): void { + expectNonEmptyArray(data); + data.forEach((item) => { + keys.forEach((key) => { + expect(item).toHaveProperty(key); + }); + }); +} + +export function expectValidNumber(value: any): void { + expect(typeof value).toBe('number'); + expect(Number.isFinite(value)).toBe(true); +} + +export function expectPositiveNumber(value: any): void { + expectValidNumber(value); + expect(value).toBeGreaterThan(0); +} + +export function expectNonNegativeNumber(value: any): void { + expectValidNumber(value); + expect(value).toBeGreaterThanOrEqual(0); +} + +export function expectValidTimestamp(timestamp: any): void { + expectValidNumber(timestamp); + const minTimestamp = 1262304000; + const maxTimestamp = 4102444800; + const timestampInSeconds = timestamp > maxTimestamp ? timestamp / 1000 : timestamp; + expect(timestampInSeconds).toBeGreaterThan(minTimestamp); + expect(timestampInSeconds).toBeLessThan(maxTimestamp); +} + +export function expectNonEmptyString(value: any): void { + expect(typeof value).toBe('string'); + expect(value.length).toBeGreaterThanOrEqual(0); +} + +export function expectValidPercentageChange(value: any): void { + if (value === null || value === undefined) return; + expectValidNumber(value); + expect(value).toBeGreaterThan(-100); + expect(value).toBeLessThan(1000000); +} + +/** + * Checks if data is fresh by verifying the most recent timestamp is within 1 day + * @param timestamps - Array of timestamps (can be numbers or strings) + * @param maxAgeInSeconds - Maximum age in seconds (default: 86400 = 1 day) + */ +export function expectFreshData( + timestamps: (number | string)[], + maxAgeInSeconds: number = 86400 +): void { + expect(timestamps.length).toBeGreaterThan(0); + + // Convert all timestamps to numbers + const numericTimestamps = timestamps.map((ts) => { + return typeof ts === 'string' ? Number(ts) : ts; + }); + + // Find the most recent timestamp + const mostRecentTimestamp = Math.max(...numericTimestamps); + + // Current time in seconds + const nowInSeconds = Math.floor(Date.now() / 1000); + + // Check if most recent data is within the specified age + const ageInSeconds = nowInSeconds - mostRecentTimestamp; + + expect(ageInSeconds).toBeLessThanOrEqual(maxAgeInSeconds); + expect(mostRecentTimestamp).toBeGreaterThan(0); +} + diff --git a/defi/api-tests/utils/validation.ts b/defi/api-tests/utils/validation.ts new file mode 100644 index 0000000000..ecf0180390 --- /dev/null +++ b/defi/api-tests/utils/validation.ts @@ -0,0 +1,169 @@ +// Generic validation utilities using Zod +// Provides reusable validation functions with detailed error reporting + +import { z } from 'zod'; + +// ============================================================================ +// Validation Result Types +// ============================================================================ + +export type ValidationSuccess = { + success: true; + data: T; +}; + +export type ValidationFailure = { + success: false; + errors: string[]; +}; + +export type ValidationResult = ValidationSuccess | ValidationFailure; + +// ============================================================================ +// Core Validation Functions +// ============================================================================ + +/** + * Validate data against a Zod schema with detailed error reporting + * @param data - Data to validate + * @param schema - Zod schema to validate against + * @param context - Optional context string for error messages + * @returns Validation result with data or errors + */ +export function validate( + data: unknown, + schema: z.ZodType, + context?: string +): ValidationResult { + const result = schema.safeParse(data); + + if (result.success) { + return { success: true, data: result.data }; + } + + const errors = result.error.issues.map((issue: z.ZodIssue) => { + const path = issue.path.join('.'); + const message = context + ? `${context}${path ? `.${path}` : ''}: ${issue.message}` + : `${path}: ${issue.message}`; + return message; + }); + + if (context && errors.length > 0) { + // Only log first 5 errors to prevent console spam + const errorsToLog = errors.slice(0, 5); + errorsToLog.forEach((err: string) => console.error(err)); + if (errors.length > 5) { + console.error(`... and ${errors.length - 5} more validation errors`); + } + } + + return { success: false, errors }; +} + +/** + * Validate an array of items with per-item error tracking + * @param data - Array to validate + * @param itemSchema - Zod schema for each item + * @param context - Optional context string for error messages + * @returns Validation result with validated data or errors + */ +export function validateArray( + data: unknown, + itemSchema: z.ZodType, + context?: string +): ValidationResult { + if (!Array.isArray(data)) { + const error = `${context || 'Array'}: Expected array, got ${typeof data}`; + if (context) console.error(error); + return { success: false, errors: [error] }; + } + + const errors: string[] = []; + const validatedData: T[] = []; + + for (let i = 0; i < data.length; i++) { + const result = itemSchema.safeParse(data[i]); + if (result.success) { + validatedData.push(result.data); + } else { + result.error.issues.forEach((issue: z.ZodIssue) => { + const path = issue.path.length > 0 ? `.${issue.path.join('.')}` : ''; + const message = `${context || 'Array'}[${i}]${path}: ${issue.message}`; + errors.push(message); + if (context) console.error(message); + }); + } + } + + if (errors.length > 0) { + return { success: false, errors }; + } + + return { success: true, data: validatedData }; +} + +/** + * Quick boolean validation check without error details + * @param data - Data to validate + * @param schema - Zod schema to validate against + * @returns True if valid, false otherwise + */ +export function isValid(data: unknown, schema: z.ZodType): data is T { + return schema.safeParse(data).success; +} + +/** + * Create a type guard function from a Zod schema + * @param schema - Zod schema + * @returns Type guard function + */ +export function createTypeGuard(schema: z.ZodType) { + return (data: unknown): data is T => { + return schema.safeParse(data).success; + }; +} + +/** + * Parse and validate data, throwing an error if invalid + * @param data - Data to parse + * @param schema - Zod schema to validate against + * @param context - Optional context for error message + * @returns Parsed and validated data + * @throws Error if validation fails + */ +export function parseOrThrow( + data: unknown, + schema: z.ZodType, + context?: string +): T { + const result = validate(data, schema, context); + + if (!result.success) { + const errorMessage = context + ? `${context}: Validation failed\n${result.errors.join('\n')}` + : `Validation failed\n${result.errors.join('\n')}`; + throw new Error(errorMessage); + } + + return result.data; +} + +// ============================================================================ +// Common Validation Schemas +// ============================================================================ + +export const commonValidation = { + nonEmptyString: z.string().min(1), + positiveNumber: z.number().positive().finite(), + nonNegativeNumber: z.number().nonnegative().finite(), + url: z.string().url(), + httpUrl: z.string().regex(/^https?:\/\/.+/), + email: z.string().email(), + timestamp: z.number().int().min(1262304000).max(4102444800), // 2010-2100 + percentage: z.number().min(-100).max(100), + hexColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/), + uuid: z.string().uuid(), + isoDate: z.string().datetime(), +} as const; + diff --git a/defi/package.json b/defi/package.json index ffe4b17882..c573f976f9 100644 --- a/defi/package.json +++ b/defi/package.json @@ -15,7 +15,7 @@ "format": "prettier --write \"src/**/*.ts\"", "serve": "node --max-old-space-size=8192 node_modules/serverless/bin/serverless offline start", "test": "jest", - "test:watch": "jest --watch", + "test:api": "cd api-tests && jest", "pretest": "npm run prebuild", "build": "sls package", "updateAdapters": "node -e \"console.log('No longer needed')\"", @@ -71,12 +71,9 @@ "babel-jest": "^29.0.0", "babel-loader": "^8.2.5", "esbuild": "^0.14.42", - "inquirer": "^8.2.6", - "inquirer-autocomplete-prompt": "^2.0.0", - "inquirer-date-prompt": "^2.0.1", - "inquirer-search-list": "^1.2.6", "jest": "^29.0.0", "jest-dynalite": "^3.5.1", + "jest-junit": "^16.0.0", "prettier": "^2.6.2", "serverless": "^3.0.0", "serverless-esbuild": "^1.30.0", @@ -87,7 +84,8 @@ "ts-jest": "^29.0.0", "ts-loader": "^9.0.0", "ts-node": "^10.8.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "zod": "^4.1.12" }, "dependencies": { "@aws-sdk/client-cloudfront": "^3.918.0", @@ -102,7 +100,6 @@ "@types/ajv": "^0.0.5", "@types/async-retry": "^1.4.8", "@types/aws-lambda": "^8.10.97", - "@types/inquirer-autocomplete-prompt": "^2.0.0", "@types/jest": "^27.5.1", "@types/lodash": "^4.14.202", "@types/node": "^18.0.0", @@ -113,7 +110,6 @@ "axios": "^1.6.5", "bignumber.js": "^9.0.1", "buffer-layout": "^1.2.2", - "colord": "^2.9.3", "dotenv": "^16.0.1", "ethers": "^6.0.0", "hyper-express": "^6.8.7", diff --git a/defi/pnpm-lock.yaml b/defi/pnpm-lock.yaml index 768c1c9a8d..e80b4617a2 100644 --- a/defi/pnpm-lock.yaml +++ b/defi/pnpm-lock.yaml @@ -47,9 +47,6 @@ importers: '@types/aws-lambda': specifier: ^8.10.97 version: 8.10.156 - '@types/inquirer-autocomplete-prompt': - specifier: ^2.0.0 - version: 2.0.2 '@types/jest': specifier: ^27.5.1 version: 27.5.2 @@ -80,9 +77,6 @@ importers: buffer-layout: specifier: ^1.2.2 version: 1.2.2 - colord: - specifier: ^2.9.3 - version: 2.9.3 dotenv: specifier: ^16.0.1 version: 16.6.1 @@ -138,24 +132,15 @@ importers: esbuild: specifier: ^0.14.42 version: 0.14.54 - inquirer: - specifier: ^8.2.6 - version: 8.2.7(@types/node@18.19.130) - inquirer-autocomplete-prompt: - specifier: ^2.0.0 - version: 2.0.1(inquirer@8.2.7(@types/node@18.19.130)) - inquirer-date-prompt: - specifier: ^2.0.1 - version: 2.0.1(inquirer@8.2.7(@types/node@18.19.130)) - inquirer-search-list: - specifier: ^1.2.6 - version: 1.2.6 jest: specifier: ^29.0.0 version: 29.7.0(@types/node@18.19.130)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.9.3)) jest-dynalite: specifier: ^3.5.1 version: 3.6.1(@aws-sdk/client-dynamodb@3.918.0)(aws-sdk@2.1692.0)(jest@29.7.0(@types/node@18.19.130)(ts-node@10.9.2(@types/node@18.19.130)(typescript@5.9.3))) + jest-junit: + specifier: ^16.0.0 + version: 16.0.0 prettier: specifier: ^2.6.2 version: 2.8.8 @@ -189,6 +174,9 @@ importers: typescript: specifier: ^5.0.0 version: 5.9.3 + zod: + specifier: ^4.1.12 + version: 4.1.12 packages: @@ -1904,12 +1892,6 @@ packages: '@types/http-cache-semantics@4.0.4': resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} - '@types/inquirer-autocomplete-prompt@2.0.2': - resolution: {integrity: sha512-Y7RM1dY3KVg11JnFkaQkTT+2Cgmn9K8De/VtrTT2a5grGIoMfkQuYM5Sss+65oiuqg1h1cTsKHG8pkoPsASdbQ==} - - '@types/inquirer@8.2.12': - resolution: {integrity: sha512-YxURZF2ZsSjU5TAe06tW0M3sL4UI9AMPA6dd8I72uOtppzNafcY38xkYgCZ/vsVOAyNdzHmvtTpLWilOrbP0dQ==} - '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -1961,9 +1943,6 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - '@types/through@0.0.33': - resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} - '@types/uuid@8.3.4': resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} @@ -2122,18 +2101,10 @@ packages: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} - ansi-escapes@3.2.0: - resolution: {integrity: sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==} - engines: {node: '>=4'} - ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} - ansi-regex@3.0.1: - resolution: {integrity: sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==} - engines: {node: '>=4'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2509,9 +2480,6 @@ packages: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} - chardet@0.4.2: - resolution: {integrity: sha512-j/Toj7f1z98Hh2cYo2BVr85EpIRWqUi7rtRSGxh/cqUjqrnJe9l9UE7IUGd2vQ2p+kSHLkSzObQPZPLUC6TQwg==} - chardet@2.1.0: resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} @@ -2556,10 +2524,6 @@ packages: resolution: {integrity: sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==} engines: {node: '>=0.10'} - cli-cursor@2.1.0: - resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} - engines: {node: '>=4'} - cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -2580,9 +2544,6 @@ packages: resolution: {integrity: sha512-he+WTicka9cl0Fg/y+YyxcN6/bfQ/1O3QmgxRXDhABKqLzvoOSM4fMzp39uMyLBulAFuywD2N7UaoQE7WaADxQ==} engines: {node: '>=8.10.0'} - cli-width@2.2.1: - resolution: {integrity: sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==} - cli-width@3.0.0: resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} engines: {node: '>= 10'} @@ -2621,9 +2582,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colord@2.9.3: - resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} - combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -3253,10 +3211,6 @@ packages: ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} - external-editor@2.2.0: - resolution: {integrity: sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==} - engines: {node: '>=0.12'} - extrareqp2@1.0.0: resolution: {integrity: sha512-Gum0g1QYb6wpPJCVypWP3bbIuaibcFiJcpuPM10YSXp/tzqi84x9PJageob+eN4xVRIOto4wjSGNLyMD54D2xA==} @@ -3310,10 +3264,6 @@ packages: fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - figures@2.0.0: - resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} - engines: {node: '>=4'} - figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -3453,10 +3403,6 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - fuzzy@0.1.3: - resolution: {integrity: sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==} - engines: {node: '>= 0.6.0'} - generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -3687,24 +3633,6 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - inquirer-autocomplete-prompt@2.0.1: - resolution: {integrity: sha512-jUHrH0btO7j5r8DTQgANf2CBkTZChoVySD8zF/wp5fZCOLIuUbleXhf4ZY5jNBOc1owA3gdfWtfZuppfYBhcUg==} - engines: {node: '>=12'} - peerDependencies: - inquirer: ^8.0.0 - - inquirer-date-prompt@2.0.1: - resolution: {integrity: sha512-1Rp9h8FJnORK6J+76jBbN4cULueGrI3vzY1yvH8M2dJ6cYm2/6ZNkOB3O7uf4LeiZUEZK14UTORrwxNGb9y/CA==} - engines: {node: '>=10'} - peerDependencies: - inquirer: '>= 7.x' - - inquirer-search-list@1.2.6: - resolution: {integrity: sha512-C4pKSW7FOYnkAloH8rB4FiM91H1v08QFZZJh6KRt//bMfdDBIhgdX8wjHvrVH2bu5oIo6wYqGpzSBxkeClPxew==} - - inquirer@3.3.0: - resolution: {integrity: sha512-h+xtnyk4EwKvFWHrUYsWErEVR+igKtLdchu+o0Z1RL7VU/jVMFbYir2bp6bAj8efFNxWqHX0dIss6fJQ+/+qeQ==} - inquirer@8.2.7: resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==} engines: {node: '>=12.0.0'} @@ -3778,10 +3706,6 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} - is-fullwidth-code-point@2.0.0: - resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==} - engines: {node: '>=4'} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -4024,6 +3948,10 @@ packages: resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-junit@16.0.0: + resolution: {integrity: sha512-A94mmw6NfJab4Fg/BlvVOUXzXgF0XIH6EmTgJ5NDPp4xoKq0Kr7sErb+4Xs9nZvu58pJojz5RFGpqnZYJTrRfQ==} + engines: {node: '>=10.12.0'} + jest-leak-detector@29.7.0: resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4421,10 +4349,6 @@ packages: engines: {node: '>=10.0.0'} hasBin: true - mimic-fn@1.2.0: - resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==} - engines: {node: '>=4'} - mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -4494,9 +4418,6 @@ packages: multipasta@0.2.7: resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==} - mute-stream@0.0.7: - resolution: {integrity: sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ==} - mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} @@ -4621,10 +4542,6 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - onetime@2.0.1: - resolution: {integrity: sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==} - engines: {node: '>=4'} - onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -4645,10 +4562,6 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} - os-tmpdir@1.0.2: - resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} - engines: {node: '>=0.10.0'} - own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -5091,10 +5004,6 @@ packages: responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} - restore-cursor@2.0.0: - resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==} - engines: {node: '>=4'} - restore-cursor@3.1.0: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} @@ -5126,12 +5035,6 @@ packages: run-series@1.1.9: resolution: {integrity: sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==} - rx-lite-aggregates@4.0.8: - resolution: {integrity: sha512-3xPNZGW93oCjiO7PtKxRK6iOVYBWBvtf9QHDfU23Oc+dLIQmAV//UnyXV/yihv81VS/UqoQPk4NegS8EFi55Hg==} - - rx-lite@4.0.8: - resolution: {integrity: sha512-Cun9QucwK6MIrp3mry/Y7hqD1oFqTYLQ4pGxaHTjIdaFDWRGGLikqp6u8LcWJnzpoALg9hap+JGk8sFIUuEGNA==} - rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -5435,10 +5338,6 @@ packages: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} - string-width@2.1.1: - resolution: {integrity: sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==} - engines: {node: '>=4'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -5465,10 +5364,6 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-ansi@4.0.0: - resolution: {integrity: sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==} - engines: {node: '>=4'} - strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -5605,10 +5500,6 @@ packages: resolution: {integrity: sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==} engines: {node: '>=0.12'} - tmp@0.0.33: - resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} - engines: {node: '>=0.6.0'} - tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -6067,6 +5958,9 @@ packages: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} + xml@1.0.1: + resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + xmlbuilder@11.0.1: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} @@ -6123,6 +6017,9 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + snapshots: 2-thenable@1.0.0: @@ -9396,15 +9293,6 @@ snapshots: '@types/http-cache-semantics@4.0.4': {} - '@types/inquirer-autocomplete-prompt@2.0.2': - dependencies: - '@types/inquirer': 8.2.12 - - '@types/inquirer@8.2.12': - dependencies: - '@types/through': 0.0.33 - rxjs: 7.8.2 - '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -9459,10 +9347,6 @@ snapshots: '@types/stack-utils@2.0.3': {} - '@types/through@0.0.33': - dependencies: - '@types/node': 18.19.130 - '@types/uuid@8.3.4': {} '@types/validator@13.15.3': {} @@ -9651,14 +9535,10 @@ snapshots: ansi-colors@4.1.3: {} - ansi-escapes@3.2.0: {} - ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 - ansi-regex@3.0.1: {} - ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -10120,8 +10000,6 @@ snapshots: char-regex@1.0.2: {} - chardet@0.4.2: {} - chardet@2.1.0: {} charm@0.1.2: {} @@ -10174,10 +10052,6 @@ snapshots: memoizee: 0.4.17 timers-ext: 0.1.8 - cli-cursor@2.1.0: - dependencies: - restore-cursor: 2.0.0 - cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -10205,8 +10079,6 @@ snapshots: dependencies: chalk: 3.0.0 - cli-width@2.2.1: {} - cli-width@3.0.0: {} cliui@7.0.4: @@ -10243,8 +10115,6 @@ snapshots: color-name@1.1.4: {} - colord@2.9.3: {} - combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -10926,12 +10796,6 @@ snapshots: dependencies: type: 2.7.3 - external-editor@2.2.0: - dependencies: - chardet: 0.4.2 - iconv-lite: 0.4.24 - tmp: 0.0.33 - extrareqp2@1.0.0(debug@4.3.7): dependencies: follow-redirects: 1.15.11(debug@4.3.7) @@ -10984,10 +10848,6 @@ snapshots: dependencies: pend: 1.2.0 - figures@2.0.0: - dependencies: - escape-string-regexp: 1.0.5 - figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -11128,8 +10988,6 @@ snapshots: functions-have-names@1.2.3: {} - fuzzy@0.1.3: {} - generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -11376,43 +11234,6 @@ snapshots: ini@1.3.8: {} - inquirer-autocomplete-prompt@2.0.1(inquirer@8.2.7(@types/node@18.19.130)): - dependencies: - ansi-escapes: 4.3.2 - figures: 3.2.0 - inquirer: 8.2.7(@types/node@18.19.130) - picocolors: 1.1.1 - run-async: 2.4.1 - rxjs: 7.8.2 - - inquirer-date-prompt@2.0.1(inquirer@8.2.7(@types/node@18.19.130)): - dependencies: - inquirer: 8.2.7(@types/node@18.19.130) - - inquirer-search-list@1.2.6: - dependencies: - chalk: 2.4.2 - figures: 2.0.0 - fuzzy: 0.1.3 - inquirer: 3.3.0 - - inquirer@3.3.0: - dependencies: - ansi-escapes: 3.2.0 - chalk: 2.4.2 - cli-cursor: 2.1.0 - cli-width: 2.2.1 - external-editor: 2.2.0 - figures: 2.0.0 - lodash: 4.17.21 - mute-stream: 0.0.7 - run-async: 2.4.1 - rx-lite: 4.0.8 - rx-lite-aggregates: 4.0.8 - string-width: 2.1.1 - strip-ansi: 4.0.0 - through: 2.3.8 - inquirer@8.2.7(@types/node@18.19.130): dependencies: '@inquirer/external-editor': 1.0.2(@types/node@18.19.130) @@ -11502,8 +11323,6 @@ snapshots: dependencies: call-bound: 1.0.4 - is-fullwidth-code-point@2.0.0: {} - is-fullwidth-code-point@3.0.0: {} is-generator-fn@2.1.0: {} @@ -11829,6 +11648,13 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + jest-junit@16.0.0: + dependencies: + mkdirp: 1.0.4 + strip-ansi: 6.0.1 + uuid: 8.3.2 + xml: 1.0.1 + jest-leak-detector@29.7.0: dependencies: jest-get-type: 29.6.3 @@ -12319,8 +12145,6 @@ snapshots: mime@3.0.0: {} - mimic-fn@1.2.0: {} - mimic-fn@2.1.0: {} mimic-fn@4.0.0: {} @@ -12374,8 +12198,6 @@ snapshots: multipasta@0.2.7: {} - mute-stream@0.0.7: {} - mute-stream@0.0.8: {} napi-macros@2.0.0: @@ -12494,10 +12316,6 @@ snapshots: dependencies: wrappy: 1.0.2 - onetime@2.0.1: - dependencies: - mimic-fn: 1.2.0 - onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -12529,8 +12347,6 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 - os-tmpdir@1.0.2: {} - own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -13021,11 +12837,6 @@ snapshots: dependencies: lowercase-keys: 2.0.0 - restore-cursor@2.0.0: - dependencies: - onetime: 2.0.1 - signal-exit: 3.0.7 - restore-cursor@3.1.0: dependencies: onetime: 5.1.2 @@ -13062,12 +12873,6 @@ snapshots: run-series@1.1.9: {} - rx-lite-aggregates@4.0.8: - dependencies: - rx-lite: 4.0.8 - - rx-lite@4.0.8: {} - rxjs@7.8.2: dependencies: tslib: 2.8.1 @@ -13476,11 +13281,6 @@ snapshots: char-regex: 1.0.2 strip-ansi: 6.0.1 - string-width@2.1.1: - dependencies: - is-fullwidth-code-point: 2.0.0 - strip-ansi: 4.0.0 - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -13524,10 +13324,6 @@ snapshots: dependencies: safe-buffer: 5.2.1 - strip-ansi@4.0.0: - dependencies: - ansi-regex: 3.0.1 - strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -13676,10 +13472,6 @@ snapshots: es5-ext: 0.10.64 next-tick: 1.1.0 - tmp@0.0.33: - dependencies: - os-tmpdir: 1.0.2 - tmpl@1.0.5: {} to-buffer@1.2.2: @@ -14140,6 +13932,8 @@ snapshots: sax: 1.2.1 xmlbuilder: 11.0.1 + xml@1.0.1: {} + xmlbuilder@11.0.1: {} xtend@4.0.2: {} @@ -14195,3 +13989,5 @@ snapshots: archiver-utils: 3.0.4 compress-commons: 4.1.2 readable-stream: 3.6.2 + + zod@4.1.12: {} diff --git a/defi/tsconfig.json b/defi/tsconfig.json index 520dc75952..5f04bbd463 100644 --- a/defi/tsconfig.json +++ b/defi/tsconfig.json @@ -30,7 +30,9 @@ }, "include": [ "src", - "/**/*.ts" + "api-tests", + "/**/*.ts", + "**/*.test.ts" ], "exclude": [ "src/setupTestEnv.js", diff --git a/defi/ui-tool/package.json b/defi/ui-tool/package.json index 34046731a6..c694763feb 100644 --- a/defi/ui-tool/package.json +++ b/defi/ui-tool/package.json @@ -15,6 +15,7 @@ }, "scripts": { "start-react": "react-scripts start", + "start-dev-server": "UI_TOOL_FORCE_DEV_MODE=true npm run start-server", "start-server": "npx ts-node --transpile-only --logError src/server.ts", "start": "react-scripts start", "build": "GENERATE_SOURCEMAP=false INLINE_RUNTIME_CHUNK=false TSC_COMPILE_ON_ERROR=true DISABLE_ESLINT_PLUGIN=true react-scripts build", diff --git a/defi/ui-tool/src/App.js b/defi/ui-tool/src/App.js index beeeb256d1..7873f339fd 100644 --- a/defi/ui-tool/src/App.js +++ b/defi/ui-tool/src/App.js @@ -257,14 +257,14 @@ const App = () => { Restart Server -{/* + */} +