hive-stream 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/AGENTS.md +35 -0
  2. package/DOCUMENTATION.md +380 -0
  3. package/README.md +113 -22
  4. package/dist/actions.d.ts +3 -3
  5. package/dist/actions.js +7 -7
  6. package/dist/actions.js.map +1 -1
  7. package/dist/adapters/base.adapter.d.ts +19 -1
  8. package/dist/adapters/base.adapter.js +16 -0
  9. package/dist/adapters/base.adapter.js.map +1 -1
  10. package/dist/adapters/mongodb.adapter.d.ts +5 -11
  11. package/dist/adapters/mongodb.adapter.js +10 -10
  12. package/dist/adapters/mongodb.adapter.js.map +1 -1
  13. package/dist/adapters/postgresql.adapter.d.ts +17 -0
  14. package/dist/adapters/postgresql.adapter.js +99 -8
  15. package/dist/adapters/postgresql.adapter.js.map +1 -1
  16. package/dist/adapters/sqlite.adapter.d.ts +17 -0
  17. package/dist/adapters/sqlite.adapter.js +99 -8
  18. package/dist/adapters/sqlite.adapter.js.map +1 -1
  19. package/dist/api.js +86 -0
  20. package/dist/api.js.map +1 -1
  21. package/dist/config.d.ts +3 -0
  22. package/dist/config.js +6 -3
  23. package/dist/config.js.map +1 -1
  24. package/dist/contracts/coinflip.contract.d.ts +8 -26
  25. package/dist/contracts/coinflip.contract.js +123 -144
  26. package/dist/contracts/coinflip.contract.js.map +1 -1
  27. package/dist/contracts/contract.d.ts +3 -0
  28. package/dist/contracts/contract.js +26 -0
  29. package/dist/contracts/contract.js.map +1 -0
  30. package/dist/contracts/dice.contract.d.ts +9 -36
  31. package/dist/contracts/dice.contract.js +135 -200
  32. package/dist/contracts/dice.contract.js.map +1 -1
  33. package/dist/contracts/exchange.contract.d.ts +11 -0
  34. package/dist/contracts/exchange.contract.js +492 -0
  35. package/dist/contracts/exchange.contract.js.map +1 -0
  36. package/dist/contracts/lotto.contract.d.ts +15 -19
  37. package/dist/contracts/lotto.contract.js +154 -162
  38. package/dist/contracts/lotto.contract.js.map +1 -1
  39. package/dist/contracts/nft.contract.d.ts +4 -0
  40. package/dist/contracts/nft.contract.js +65 -0
  41. package/dist/contracts/nft.contract.js.map +1 -1
  42. package/dist/contracts/poll.contract.d.ts +4 -0
  43. package/dist/contracts/poll.contract.js +105 -0
  44. package/dist/contracts/poll.contract.js.map +1 -0
  45. package/dist/contracts/rps.contract.d.ts +9 -0
  46. package/dist/contracts/rps.contract.js +217 -0
  47. package/dist/contracts/rps.contract.js.map +1 -0
  48. package/dist/contracts/tipjar.contract.d.ts +4 -0
  49. package/dist/contracts/tipjar.contract.js +60 -0
  50. package/dist/contracts/tipjar.contract.js.map +1 -0
  51. package/dist/contracts/token.contract.d.ts +3 -17
  52. package/dist/contracts/token.contract.js +128 -80
  53. package/dist/contracts/token.contract.js.map +1 -1
  54. package/dist/exchanges/coingecko.d.ts +7 -1
  55. package/dist/exchanges/coingecko.js +38 -21
  56. package/dist/exchanges/coingecko.js.map +1 -1
  57. package/dist/exchanges/exchange.d.ts +15 -8
  58. package/dist/exchanges/exchange.js +65 -11
  59. package/dist/exchanges/exchange.js.map +1 -1
  60. package/dist/hive-rates.d.ts +29 -4
  61. package/dist/hive-rates.js +179 -92
  62. package/dist/hive-rates.js.map +1 -1
  63. package/dist/index.d.ts +10 -3
  64. package/dist/index.js +18 -4
  65. package/dist/index.js.map +1 -1
  66. package/dist/streamer.d.ts +101 -8
  67. package/dist/streamer.js +410 -140
  68. package/dist/streamer.js.map +1 -1
  69. package/dist/test.js +11 -12
  70. package/dist/test.js.map +1 -1
  71. package/dist/types/hive-stream.d.ts +85 -14
  72. package/dist/types/rates.d.ts +47 -0
  73. package/dist/types/rates.js +29 -0
  74. package/dist/types/rates.js.map +1 -0
  75. package/dist/utils.d.ts +318 -11
  76. package/dist/utils.js +804 -115
  77. package/dist/utils.js.map +1 -1
  78. package/examples/contracts/README.md +8 -0
  79. package/examples/contracts/exchange.ts +38 -0
  80. package/examples/contracts/poll.ts +21 -0
  81. package/examples/contracts/rps.ts +19 -0
  82. package/examples/contracts/tipjar.ts +19 -0
  83. package/package.json +20 -19
  84. package/tests/actions.spec.ts +7 -7
  85. package/tests/adapters/actions-persistence.spec.ts +4 -4
  86. package/tests/adapters/sqlite.adapter.spec.ts +2 -2
  87. package/tests/contracts/coinflip.contract.spec.ts +26 -154
  88. package/tests/contracts/dice.contract.spec.ts +24 -140
  89. package/tests/contracts/exchange.contract.spec.ts +84 -0
  90. package/tests/contracts/lotto.contract.spec.ts +30 -295
  91. package/tests/contracts/token.contract.spec.ts +72 -316
  92. package/tests/exchanges/coingecko.exchange.spec.ts +169 -0
  93. package/tests/exchanges/exchange.base.spec.ts +246 -0
  94. package/tests/helpers/mock-fetch.ts +165 -0
  95. package/tests/hive-chain-features.spec.ts +238 -0
  96. package/tests/hive-rates.spec.ts +443 -0
  97. package/tests/integration/hive-rates.integration.spec.ts +35 -0
  98. package/tests/streamer-actions.spec.ts +29 -18
  99. package/tests/streamer.spec.ts +142 -49
  100. package/tests/types/rates.spec.ts +216 -0
  101. package/tests/utils.spec.ts +27 -6
@@ -1,334 +1,90 @@
1
- import { TokenContract } from '../../src/contracts/token.contract';
2
- import { MockAdapter } from '../helpers/mock-adapter';
1
+ import { createTokenContract } from '../../src/contracts/token.contract';
2
+ import { Streamer } from '../../src/streamer';
3
+ import { createMockAdapter } from '../helpers/mock-adapter';
4
+
5
+ const createContext = (streamer: Streamer, adapter: any, sender: string) => ({
6
+ trigger: 'custom_json' as const,
7
+ streamer,
8
+ adapter,
9
+ config: streamer['config'],
10
+ block: { number: 123, id: 'block123', previousId: 'prevblock123', time: new Date() },
11
+ transaction: { id: 'txn123' },
12
+ sender,
13
+ customJson: { id: 'hivestream', json: {}, isSignedWithActiveKey: true }
14
+ });
3
15
 
4
16
  describe('TokenContract', () => {
5
- let tokenContract: TokenContract;
6
- let mockAdapter: MockAdapter;
7
- let mockStreamer: any;
8
-
9
- beforeEach(() => {
10
- mockAdapter = new MockAdapter();
11
- mockStreamer = {
12
- getAdapter: () => mockAdapter
13
- };
14
-
15
- tokenContract = new TokenContract();
16
- tokenContract._instance = mockStreamer;
17
- tokenContract.updateBlockInfo(12345, 'block123', 'prevblock123', 'txn123');
17
+ let streamer: Streamer;
18
+ let adapter: any;
19
+ let contract: ReturnType<typeof createTokenContract>;
20
+
21
+ beforeEach(async () => {
22
+ streamer = new Streamer();
23
+ adapter = createMockAdapter();
24
+ await streamer.registerAdapter(adapter);
25
+ contract = createTokenContract();
26
+ await streamer.registerContract(contract);
18
27
  });
19
28
 
20
- describe('createToken', () => {
21
- it('should create a new token successfully', async () => {
22
- mockAdapter.reset();
23
- tokenContract.create();
24
- mockAdapter.setQueryResult([]); // No existing token with same symbol
25
-
26
- const payload = {
27
- symbol: 'TEST',
28
- name: 'Test Token',
29
- precision: 3,
30
- maxSupply: '1000000'
31
- };
32
-
33
- await (tokenContract as any).createToken(payload, { sender: 'alice' });
34
-
35
- const insertQuery = mockAdapter.queries.find(q => q.includes('INSERT INTO tokens'));
36
- expect(insertQuery).toBeDefined();
37
- expect(mockAdapter.events.length).toBe(1);
38
- expect(mockAdapter.events[0].action).toBe('createToken');
39
- });
40
-
41
- it('should reject invalid token symbol', async () => {
42
- const payload = {
43
- symbol: 'invalid-symbol',
44
- name: 'Test Token',
45
- maxSupply: '1000000'
46
- };
47
-
48
- await expect((tokenContract as any).createToken(payload, { sender: 'alice' }))
49
- .rejects.toThrow('Symbol must be 1-10 uppercase alphanumeric characters');
50
- });
51
-
52
- it('should reject invalid max supply', async () => {
53
- const payload = {
54
- symbol: 'TEST',
55
- name: 'Test Token',
56
- maxSupply: '0'
57
- };
58
-
59
- await expect((tokenContract as any).createToken(payload, { sender: 'alice' }))
60
- .rejects.toThrow('Maximum supply must be between 1 and 9007199254740991');
61
- });
62
-
63
- it('should reject max supply too large', async () => {
64
- const payload = {
65
- symbol: 'TEST',
66
- name: 'Test Token',
67
- maxSupply: '9007199254740992'
68
- };
69
-
70
- await expect((tokenContract as any).createToken(payload, { sender: 'alice' }))
71
- .rejects.toThrow('Maximum supply must be between 1 and 9007199254740991');
72
- });
73
-
74
- it('should reject invalid precision', async () => {
75
- const payload = {
76
- symbol: 'TEST',
77
- name: 'Test Token',
78
- precision: 9,
79
- maxSupply: '1000000'
80
- };
81
-
82
- await expect((tokenContract as any).createToken(payload, { sender: 'alice' }))
83
- .rejects.toThrow('Precision must be between 0 and 8');
84
- });
85
-
86
- it('should create token with URL', async () => {
87
- mockAdapter.reset();
88
- tokenContract.create();
89
- mockAdapter.setQueryResult([]); // No existing token with same symbol
90
-
91
- const payload = {
92
- symbol: 'TEST',
93
- name: 'Test Token',
94
- url: 'https://example.com/token',
95
- precision: 3,
96
- maxSupply: '1000000'
97
- };
98
-
99
- await (tokenContract as any).createToken(payload, { sender: 'alice' });
100
-
101
- const insertQuery = mockAdapter.queries.find(q => q.includes('INSERT INTO tokens'));
102
- expect(insertQuery).toBeDefined();
103
- expect(mockAdapter.events.length).toBe(1);
104
- expect(mockAdapter.events[0].action).toBe('createToken');
105
- });
106
-
107
- it('should reject URL too long', async () => {
108
- const longUrl = 'https://example.com/' + 'a'.repeat(250);
109
- const payload = {
110
- symbol: 'TEST',
111
- name: 'Test Token',
112
- url: longUrl,
113
- maxSupply: '1000000'
114
- };
115
-
116
- await expect((tokenContract as any).createToken(payload, { sender: 'alice' }))
117
- .rejects.toThrow('URL must be 256 characters or less');
118
- });
29
+ afterEach(async () => {
30
+ await streamer.stop();
119
31
  });
120
32
 
121
- describe('issueTokens', () => {
122
- it('should issue tokens successfully', async () => {
123
- mockAdapter.reset();
124
- mockAdapter.setTestContext({ noExistingBalance: true });
125
- tokenContract.create();
126
-
127
- mockAdapter.setQueryResult([{
128
- symbol: 'TEST',
129
- name: 'Test Token',
130
- creator: 'alice',
131
- precision: 3,
132
- current_supply: '0',
133
- max_supply: '1000000'
134
- }]);
135
-
136
- const payload = {
137
- symbol: 'TEST',
138
- to: 'bob',
139
- amount: '100',
140
- memo: 'Initial distribution'
141
- };
142
-
143
- await (tokenContract as any).issueTokens(payload, { sender: 'alice' });
144
-
145
- const updateQuery = mockAdapter.queries.find(q => q.includes('UPDATE tokens SET current_supply'));
146
- const insertQuery = mockAdapter.queries.find(q => q.includes('INSERT INTO token_balances'));
147
- expect(updateQuery).toBeDefined();
148
- expect(insertQuery).toBeDefined();
149
- expect(mockAdapter.events.length).toBe(1);
150
- expect(mockAdapter.events[0].action).toBe('issueTokens');
151
- });
152
-
153
- it('should reject issuance by non-creator', async () => {
154
- mockAdapter.reset();
155
- tokenContract.create();
156
-
157
- mockAdapter.setQueryResult([{
158
- symbol: 'TEST',
159
- name: 'Test Token',
160
- creator: 'alice',
161
- precision: 3,
162
- current_supply: '0',
163
- max_supply: '1000000'
164
- }]);
165
-
166
- const payload = {
167
- symbol: 'TEST',
168
- to: 'bob',
169
- amount: '100'
170
- };
171
-
172
- await expect((tokenContract as any).issueTokens(payload, { sender: 'charlie' }))
173
- .rejects.toThrow('Only the token creator can issue new tokens');
174
- });
175
-
176
- it('should reject issuance exceeding max supply', async () => {
177
- mockAdapter.reset();
178
- mockAdapter.setTestContext({ maxSupplyExceeded: true });
179
- tokenContract.create();
180
-
181
- mockAdapter.setQueryResult([{
182
- symbol: 'TEST',
183
- name: 'Test Token',
184
- creator: 'alice',
185
- precision: 3,
186
- current_supply: '999999',
187
- max_supply: '1000000'
188
- }]);
33
+ test('Creates a token', async () => {
34
+ const ctx = createContext(streamer, adapter, 'alice');
35
+ const payload = {
36
+ symbol: 'TEST',
37
+ name: 'Test Token',
38
+ url: 'https://example.com',
39
+ precision: 3,
40
+ maxSupply: '1000000'
41
+ };
189
42
 
190
- const payload = {
191
- symbol: 'TEST',
192
- to: 'bob',
193
- amount: '100'
194
- };
43
+ await contract.actions.createToken.handler(payload, ctx);
195
44
 
196
- await expect((tokenContract as any).issueTokens(payload, { sender: 'alice' }))
197
- .rejects.toThrow('Cannot issue tokens: would exceed maximum supply');
198
- });
45
+ expect(adapter.queries.join(' ')).toContain('INSERT INTO tokens');
46
+ expect(adapter.events.length).toBeGreaterThan(0);
47
+ expect(adapter.events[0].action).toBe('createToken');
199
48
  });
200
49
 
201
- describe('transferTokens', () => {
202
- it('should transfer tokens successfully', async () => {
203
- mockAdapter.reset();
204
- tokenContract.create();
205
-
206
- const payload = {
207
- symbol: 'TEST',
208
- to: 'bob',
209
- amount: '100',
210
- memo: 'Payment'
211
- };
212
-
213
- await (tokenContract as any).transferTokens(payload, { sender: 'alice' });
214
-
215
- const updateQuery = mockAdapter.queries.find(q => q.includes('UPDATE token_balances SET balance'));
216
- const insertQuery = mockAdapter.queries.find(q => q.includes('INSERT INTO token_transfers'));
217
- expect(updateQuery).toBeDefined();
218
- expect(insertQuery).toBeDefined();
219
- expect(mockAdapter.events.length).toBe(1);
220
- expect(mockAdapter.events[0].action).toBe('transferTokens');
221
- });
222
-
223
- it('should reject transfer with insufficient balance', async () => {
224
- mockAdapter.reset();
225
- mockAdapter.setTestContext({ insufficientBalance: true });
226
- tokenContract.create();
227
-
228
- const payload = {
229
- symbol: 'TEST',
230
- to: 'bob',
231
- amount: '100'
232
- };
233
-
234
- await expect((tokenContract as any).transferTokens(payload, { sender: 'alice' }))
235
- .rejects.toThrow('Insufficient balance');
236
- });
237
-
238
- it('should reject transfer of non-existent token', async () => {
239
- mockAdapter.reset();
240
- mockAdapter.setTestContext({ nonExistentToken: 'NONEXISTENT' });
241
- tokenContract.create();
242
-
243
- const payload = {
244
- symbol: 'NONEXISTENT',
245
- to: 'bob',
246
- amount: '100'
247
- };
50
+ test('Rejects duplicate token symbols', async () => {
51
+ adapter.setTestContext({ existingToken: 'TEST' });
52
+ const ctx = createContext(streamer, adapter, 'alice');
53
+ const payload = {
54
+ symbol: 'TEST',
55
+ name: 'Test Token',
56
+ maxSupply: '1000000'
57
+ };
248
58
 
249
- await expect((tokenContract as any).transferTokens(payload, { sender: 'alice' }))
250
- .rejects.toThrow('Token NONEXISTENT does not exist');
251
- });
59
+ await expect(contract.actions.createToken.handler(payload, ctx))
60
+ .rejects
61
+ .toThrow('Token with symbol TEST already exists');
252
62
  });
253
63
 
254
- describe('getBalance', () => {
255
- it('should return balance for existing account', async () => {
256
- mockAdapter.reset();
257
- tokenContract.create();
258
-
259
- mockAdapter.setQueryResult([{ balance: '500' }]);
260
-
261
- const payload = {
262
- account: 'alice',
263
- symbol: 'TEST'
264
- };
265
-
266
- await (tokenContract as any).getBalance(payload, { sender: 'bob' });
267
-
268
- const selectQuery = mockAdapter.queries.find(q => q.includes('SELECT balance FROM token_balances'));
269
- expect(selectQuery).toBeDefined();
270
- expect(mockAdapter.events.length).toBe(1);
271
- expect(mockAdapter.events[0].action).toBe('getBalance');
272
- });
273
-
274
- it('should return zero balance for non-existent account', async () => {
275
- mockAdapter.reset();
276
- mockAdapter.setTestContext({ zeroBalance: true });
277
- tokenContract.create();
278
-
279
- const payload = {
280
- account: 'alice',
281
- symbol: 'TEST'
282
- };
283
-
284
- await (tokenContract as any).getBalance(payload, { sender: 'bob' });
64
+ test('Prevents non-creators from issuing tokens', async () => {
65
+ const ctx = createContext(streamer, adapter, 'bob');
66
+ const payload = {
67
+ symbol: 'TEST',
68
+ to: 'carol',
69
+ amount: '100'
70
+ };
285
71
 
286
- expect(mockAdapter.events[0].data.data.balance).toBe('0');
287
- });
72
+ await expect(contract.actions.issueTokens.handler(payload, ctx))
73
+ .rejects
74
+ .toThrow('Only the token creator can issue new tokens');
288
75
  });
289
76
 
290
- describe('getTokenInfo', () => {
291
- it('should return token information', async () => {
292
- mockAdapter.reset();
293
- tokenContract.create();
294
-
295
- const tokenData = {
296
- symbol: 'TEST',
297
- name: 'Test Token',
298
- url: 'https://example.com/token',
299
- precision: 3,
300
- max_supply: '1000000',
301
- current_supply: '500000',
302
- creator: 'alice',
303
- created_at: new Date()
304
- };
305
-
306
- mockAdapter.setQueryResult([tokenData]);
307
-
308
- const payload = {
309
- symbol: 'TEST'
310
- };
311
-
312
- await (tokenContract as any).getTokenInfo(payload, { sender: 'bob' });
313
-
314
- const selectQuery = mockAdapter.queries.find(q => q.includes('SELECT * FROM tokens'));
315
- expect(selectQuery).toBeDefined();
316
- expect(mockAdapter.events.length).toBe(1);
317
- expect(mockAdapter.events[0].action).toBe('getTokenInfo');
318
- expect(mockAdapter.events[0].data.data.token_info).toEqual(tokenData);
319
- });
320
-
321
- it('should reject request for non-existent token', async () => {
322
- mockAdapter.reset();
323
- mockAdapter.setTestContext({ nonExistentToken: 'NONEXISTENT' });
324
- tokenContract.create();
325
-
326
- const payload = {
327
- symbol: 'NONEXISTENT'
328
- };
77
+ test('Prevents transfers with insufficient balance', async () => {
78
+ adapter.setTestContext({ insufficientBalance: true });
79
+ const ctx = createContext(streamer, adapter, 'alice');
80
+ const payload = {
81
+ symbol: 'TEST',
82
+ to: 'bob',
83
+ amount: '9999'
84
+ };
329
85
 
330
- await expect((tokenContract as any).getTokenInfo(payload, { sender: 'bob' }))
331
- .rejects.toThrow('Token NONEXISTENT does not exist');
332
- });
86
+ await expect(contract.actions.transferTokens.handler(payload, ctx))
87
+ .rejects
88
+ .toThrow('Insufficient balance');
333
89
  });
334
- });
90
+ });
@@ -0,0 +1,169 @@
1
+ import { CoinGeckoExchange } from '../../src/exchanges/coingecko';
2
+ import { NetworkError, ValidationError } from '../../src/types/rates';
3
+ import { mockSuccessfulApis, mockNetworkErrors, mockHttpErrors, mockInvalidResponses, cleanupMocks } from '../helpers/mock-fetch';
4
+
5
+ describe('CoinGeckoExchange', () => {
6
+ let exchange: CoinGeckoExchange;
7
+
8
+ beforeEach(() => {
9
+ exchange = new CoinGeckoExchange();
10
+ });
11
+
12
+ afterEach(() => {
13
+ cleanupMocks();
14
+ });
15
+
16
+ describe('fetchRates', () => {
17
+ it('should fetch rates successfully', async () => {
18
+ mockSuccessfulApis();
19
+
20
+ const success = await exchange.fetchRates();
21
+
22
+ expect(success).toBe(true);
23
+ expect(exchange.rateUsdHive).toBe(0.25);
24
+ expect(exchange.rateUsdHbd).toBe(1.00);
25
+ });
26
+
27
+ it('should handle network errors', async () => {
28
+ mockNetworkErrors();
29
+
30
+ await expect(exchange.fetchRates()).rejects.toThrow(NetworkError);
31
+ });
32
+
33
+ it('should handle HTTP errors', async () => {
34
+ mockHttpErrors(500);
35
+
36
+ await expect(exchange.fetchRates()).rejects.toThrow(NetworkError);
37
+ });
38
+
39
+ it('should handle invalid JSON responses', async () => {
40
+ mockInvalidResponses();
41
+
42
+ await expect(exchange.fetchRates()).rejects.toThrow(ValidationError);
43
+ });
44
+
45
+ it('should retry on failure', async () => {
46
+ const retryExchange = new CoinGeckoExchange({ maxRetries: 3, retryDelay: 100 });
47
+
48
+ // Mock first two calls to fail, third to succeed
49
+ let callCount = 0;
50
+ global.fetch = jest.fn().mockImplementation(() => {
51
+ callCount++;
52
+ if (callCount <= 2) {
53
+ return Promise.reject(new Error('Network error'));
54
+ }
55
+ return Promise.resolve({
56
+ ok: true,
57
+ json: () => Promise.resolve({
58
+ hive: { usd: 0.25 },
59
+ 'hive_dollar': { usd: 1.00 }
60
+ })
61
+ });
62
+ });
63
+
64
+ const success = await retryExchange.updateRates();
65
+
66
+ expect(success).toBe(true);
67
+ expect(retryExchange.rateUsdHive).toBe(0.25);
68
+ expect(callCount).toBe(3);
69
+ }, 10000); // Increase timeout for retry test
70
+
71
+ it('should respect timeout', async () => {
72
+ const timeoutExchange = new CoinGeckoExchange({ timeout: 100 });
73
+
74
+ // Mock a delayed response
75
+ global.fetch = jest.fn().mockImplementation(() =>
76
+ new Promise((resolve) => {
77
+ setTimeout(() => resolve({
78
+ ok: true,
79
+ json: () => Promise.resolve({ hive: { usd: 0.25 } })
80
+ }), 200); // Longer than timeout
81
+ })
82
+ );
83
+
84
+ await expect(timeoutExchange.fetchRates()).rejects.toThrow();
85
+ });
86
+
87
+ it('should validate rate values', async () => {
88
+ // Mock response with invalid rates
89
+ global.fetch = jest.fn().mockResolvedValue({
90
+ ok: true,
91
+ json: () => Promise.resolve({
92
+ hive: { usd: -1 }, // Invalid negative rate
93
+ 'hive_dollar': { usd: 1.00 }
94
+ })
95
+ });
96
+
97
+ await expect(exchange.fetchRates()).rejects.toThrow(ValidationError);
98
+ });
99
+
100
+ it('should handle missing rate data', async () => {
101
+ // Mock response with missing hive data
102
+ global.fetch = jest.fn().mockResolvedValue({
103
+ ok: true,
104
+ json: () => Promise.resolve({
105
+ 'hive_dollar': { usd: 1.00 }
106
+ // Missing hive data
107
+ })
108
+ });
109
+
110
+ await expect(exchange.fetchRates()).rejects.toThrow(ValidationError);
111
+ });
112
+ });
113
+
114
+ describe('caching', () => {
115
+ it('should use cached rates when valid', async () => {
116
+ mockSuccessfulApis();
117
+
118
+ // First fetch
119
+ const rates1 = await exchange.updateRates();
120
+
121
+ // Second fetch should use cache
122
+ const rates2 = await exchange.updateRates();
123
+
124
+ expect(rates1).toBe(true);
125
+ expect(rates2).toBe(false);
126
+ expect(global.fetch).toHaveBeenCalledTimes(1);
127
+ });
128
+
129
+ it('should refresh cache when expired', async () => {
130
+ const shortCacheExchange = new CoinGeckoExchange({ cacheDuration: 100 });
131
+ mockSuccessfulApis();
132
+
133
+ // First fetch
134
+ await shortCacheExchange.updateRates();
135
+
136
+ // Wait for cache to expire
137
+ await new Promise(resolve => setTimeout(resolve, 150));
138
+
139
+ // Second fetch should hit API again
140
+ await shortCacheExchange.updateRates();
141
+
142
+ expect(global.fetch).toHaveBeenCalledTimes(2);
143
+ });
144
+
145
+ it('should report cache validity correctly', async () => {
146
+ mockSuccessfulApis();
147
+
148
+ expect(exchange.isCacheValid()).toBe(false);
149
+
150
+ await exchange.updateRates();
151
+
152
+ expect(exchange.isCacheValid()).toBe(true);
153
+ });
154
+
155
+ it('should report last fetch time', async () => {
156
+ mockSuccessfulApis();
157
+
158
+ expect(exchange.getLastFetchTime()).toBeUndefined();
159
+
160
+ const beforeFetch = Date.now();
161
+ await exchange.updateRates();
162
+ const afterFetch = Date.now();
163
+
164
+ const lastFetchTime = exchange.getLastFetchTime();
165
+ expect(lastFetchTime).toBeGreaterThanOrEqual(beforeFetch);
166
+ expect(lastFetchTime).toBeLessThanOrEqual(afterFetch);
167
+ });
168
+ });
169
+ });