forked from SmartDropLabs/smartdrop-backend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcoingecko.test.js
More file actions
203 lines (162 loc) · 6.34 KB
/
Copy pathcoingecko.test.js
File metadata and controls
203 lines (162 loc) · 6.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
'use strict';
const mockGet = jest.fn();
const mockAxiosCreate = jest.fn(() => ({ get: mockGet }));
const mockLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};
jest.mock('axios', () => ({
create: mockAxiosCreate,
}));
jest.mock('../src/config', () => ({
coingecko: {
apiKey: 'cg-test-key',
baseUrl: 'https://api.coingecko.test/api/v3',
},
priceSources: {
circuitCooldownMs: 900000,
circuitReminderIntervalMs: 300000,
},
}));
jest.mock('../src/logger', () => mockLogger);
function priceResponse(coinId, price) {
return { data: { [coinId]: { usd: price } } };
}
function loadSource() {
jest.resetModules();
mockGet.mockReset();
mockAxiosCreate.mockClear();
mockAxiosCreate.mockReturnValue({ get: mockGet });
mockLogger.warn.mockClear();
mockLogger.debug.mockClear();
mockLogger.error.mockClear();
return require('../src/services/sources/coingecko');
}
describe('CoinGecko source', () => {
test('returns USD price for a supported asset (XLM)', async () => {
const coingecko = loadSource();
mockGet.mockResolvedValueOnce(priceResponse('stellar', 0.11));
const price = await coingecko.fetchPrice('XLM');
expect(price).toBe(0.11);
expect(mockAxiosCreate).toHaveBeenCalledWith({
baseURL: 'https://api.coingecko.test/api/v3',
headers: { Accept: 'application/json', 'x-cg-demo-api-key': 'cg-test-key' },
timeout: 10000,
});
expect(mockGet).toHaveBeenCalledWith('/simple/price', {
params: { ids: 'stellar', vs_currencies: 'usd' },
});
});
test('returns null for an unsupported asset without calling CoinGecko', async () => {
const coingecko = loadSource();
const price = await coingecko.fetchPrice('DOGE');
expect(price).toBeNull();
expect(mockGet).not.toHaveBeenCalled();
});
test('returns null when the response omits usd price', async () => {
const coingecko = loadSource();
mockGet.mockResolvedValueOnce({ data: { stellar: {} } });
await expect(coingecko.fetchPrice('XLM')).resolves.toBeNull();
});
test('throws non-retryable HTTP 401 errors for an invalid API key', async () => {
const coingecko = loadSource();
const authError = new Error('unauthorized');
authError.response = { status: 401 };
mockGet.mockRejectedValueOnce(authError);
await expect(coingecko.fetchPrice('XLM')).rejects.toThrow('unauthorized');
expect(authError.nonRetryable).toBe(true);
expect(mockLogger.warn).toHaveBeenCalledWith('CoinGecko authentication failed', { assetCode: 'XLM' });
});
test('returns null and logs on HTTP 429 rate limits, without throwing', async () => {
const coingecko = loadSource();
const rateLimitError = new Error('too many requests');
rateLimitError.response = { status: 429 };
mockGet.mockRejectedValueOnce(rateLimitError);
const price = await coingecko.fetchPrice('XLM');
expect(price).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith('CoinGecko rate limit hit', { assetCode: 'XLM' });
});
test('returns null and logs a generic failure for other errors, without throwing', async () => {
const coingecko = loadSource();
const networkError = new Error('ECONNRESET');
mockGet.mockRejectedValueOnce(networkError);
const price = await coingecko.fetchPrice('XLM');
expect(price).toBeNull();
expect(mockLogger.warn).toHaveBeenCalledWith(
'CoinGecko price fetch failed',
{ assetCode: 'XLM', error: 'ECONNRESET' }
);
});
describe('circuit breaker (#95)', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
test('opens the circuit on a 401 and skips the HTTP request on the next fetch', async () => {
const coingecko = loadSource();
const authError = new Error('unauthorized');
authError.response = { status: 401 };
mockGet.mockRejectedValueOnce(authError);
await expect(coingecko.fetchPrice('XLM')).rejects.toThrow('unauthorized');
expect(coingecko.getCircuitState().open).toBe(true);
mockGet.mockClear();
const price = await coingecko.fetchPrice('XLM');
expect(price).toBeNull();
expect(mockGet).not.toHaveBeenCalled();
});
test('retries after cooldown and closes the circuit on success', async () => {
const coingecko = loadSource();
const authError = new Error('unauthorized');
authError.response = { status: 401 };
mockGet.mockRejectedValueOnce(authError);
await expect(coingecko.fetchPrice('XLM')).rejects.toThrow('unauthorized');
jest.advanceTimersByTime(900001);
mockGet.mockClear();
mockGet.mockResolvedValueOnce(priceResponse('stellar', 0.12));
const price = await coingecko.fetchPrice('XLM');
expect(mockGet).toHaveBeenCalledTimes(1);
expect(price).toBe(0.12);
expect(coingecko.getCircuitState()).toEqual({
source: 'coingecko',
open: false,
openUntil: null,
last_success_at: '2026-01-01T00:15:00.001Z',
});
});
test('403s (CDN/firewall block) are unaffected by the circuit breaker', async () => {
const coingecko = loadSource();
const forbiddenError = new Error('forbidden');
forbiddenError.response = { status: 403 };
mockGet.mockRejectedValue(forbiddenError);
const price = await coingecko.fetchPrice('XLM');
expect(price).toBeNull();
expect(coingecko.getCircuitState().open).toBe(false);
});
test('429s are unaffected by the circuit breaker', async () => {
const coingecko = loadSource();
const rateLimitError = new Error('too many requests');
rateLimitError.response = { status: 429 };
mockGet.mockRejectedValue(rateLimitError);
await coingecko.fetchPrice('XLM');
const price = await coingecko.fetchPrice('XLM');
expect(price).toBeNull();
expect(mockGet).toHaveBeenCalledTimes(2);
expect(coingecko.getCircuitState().open).toBe(false);
});
});
describe('isSupported', () => {
test('is true for XLM', () => {
const coingecko = loadSource();
expect(coingecko.isSupported('XLM')).toBe(true);
});
test('is false for an asset with no CoinGecko mapping (e.g. USDC)', () => {
const coingecko = loadSource();
expect(coingecko.isSupported('USDC')).toBe(false);
});
});
});