payment-kit 1.24.3 → 1.25.0

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 (117) hide show
  1. package/api/src/crons/overdue-detection.ts +10 -1
  2. package/api/src/index.ts +3 -0
  3. package/api/src/libs/credit-utils.ts +21 -0
  4. package/api/src/libs/discount/discount.ts +13 -0
  5. package/api/src/libs/env.ts +5 -0
  6. package/api/src/libs/error.ts +14 -0
  7. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  8. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  9. package/api/src/libs/exchange-rate/index.ts +5 -0
  10. package/api/src/libs/exchange-rate/service.ts +583 -0
  11. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  12. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  13. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  14. package/api/src/libs/exchange-rate/types.ts +114 -0
  15. package/api/src/libs/exchange-rate/validator.ts +319 -0
  16. package/api/src/libs/invoice-quote.ts +158 -0
  17. package/api/src/libs/invoice.ts +143 -7
  18. package/api/src/libs/math-utils.ts +46 -0
  19. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  20. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  21. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  22. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  23. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  24. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  25. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  26. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  27. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  28. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  29. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  30. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  31. package/api/src/libs/payment.ts +1 -1
  32. package/api/src/libs/price.ts +4 -1
  33. package/api/src/libs/queue/index.ts +8 -0
  34. package/api/src/libs/quote-service.ts +1132 -0
  35. package/api/src/libs/quote-validation.ts +388 -0
  36. package/api/src/libs/session.ts +686 -39
  37. package/api/src/libs/slippage.ts +135 -0
  38. package/api/src/libs/subscription.ts +185 -15
  39. package/api/src/libs/util.ts +64 -3
  40. package/api/src/locales/en.ts +50 -0
  41. package/api/src/locales/zh.ts +48 -0
  42. package/api/src/queues/auto-recharge.ts +295 -21
  43. package/api/src/queues/exchange-rate-health.ts +242 -0
  44. package/api/src/queues/invoice.ts +48 -1
  45. package/api/src/queues/notification.ts +190 -3
  46. package/api/src/queues/payment.ts +177 -7
  47. package/api/src/queues/subscription.ts +436 -6
  48. package/api/src/routes/auto-recharge-configs.ts +71 -6
  49. package/api/src/routes/checkout-sessions.ts +1730 -81
  50. package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
  51. package/api/src/routes/connect/change-payer.ts +2 -0
  52. package/api/src/routes/connect/change-payment.ts +61 -8
  53. package/api/src/routes/connect/change-plan.ts +161 -17
  54. package/api/src/routes/connect/collect.ts +9 -6
  55. package/api/src/routes/connect/delegation.ts +1 -0
  56. package/api/src/routes/connect/pay.ts +157 -0
  57. package/api/src/routes/connect/setup.ts +32 -10
  58. package/api/src/routes/connect/shared.ts +159 -13
  59. package/api/src/routes/connect/subscribe.ts +32 -9
  60. package/api/src/routes/credit-grants.ts +99 -0
  61. package/api/src/routes/exchange-rate-providers.ts +248 -0
  62. package/api/src/routes/exchange-rates.ts +87 -0
  63. package/api/src/routes/index.ts +4 -0
  64. package/api/src/routes/invoices.ts +280 -2
  65. package/api/src/routes/meter-events.ts +3 -0
  66. package/api/src/routes/payment-links.ts +13 -0
  67. package/api/src/routes/prices.ts +84 -2
  68. package/api/src/routes/subscriptions.ts +526 -15
  69. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  70. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  71. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  72. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  73. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  74. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  75. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  76. package/api/src/store/models/auto-recharge-config.ts +12 -0
  77. package/api/src/store/models/checkout-session.ts +7 -0
  78. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  79. package/api/src/store/models/index.ts +6 -0
  80. package/api/src/store/models/payment-intent.ts +6 -0
  81. package/api/src/store/models/price-quote.ts +284 -0
  82. package/api/src/store/models/price.ts +53 -5
  83. package/api/src/store/models/subscription.ts +11 -0
  84. package/api/src/store/models/types.ts +61 -1
  85. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  86. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  87. package/api/tests/libs/quote-service.spec.ts +199 -0
  88. package/api/tests/libs/session.spec.ts +464 -0
  89. package/api/tests/libs/slippage.spec.ts +109 -0
  90. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  91. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  92. package/api/tests/models/price-dynamic.spec.ts +100 -0
  93. package/api/tests/models/price-quote.spec.ts +112 -0
  94. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  95. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  96. package/blocklet.yml +1 -1
  97. package/package.json +7 -6
  98. package/src/components/customer/credit-overview.tsx +14 -0
  99. package/src/components/discount/discount-info.tsx +8 -2
  100. package/src/components/invoice/list.tsx +146 -16
  101. package/src/components/invoice/table.tsx +276 -71
  102. package/src/components/invoice-pdf/template.tsx +3 -7
  103. package/src/components/metadata/form.tsx +6 -8
  104. package/src/components/price/form.tsx +519 -149
  105. package/src/components/promotion/active-redemptions.tsx +5 -3
  106. package/src/components/quote/info.tsx +234 -0
  107. package/src/hooks/subscription.ts +132 -2
  108. package/src/locales/en.tsx +145 -0
  109. package/src/locales/zh.tsx +143 -1
  110. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  111. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  112. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  113. package/src/pages/admin/products/index.tsx +12 -1
  114. package/src/pages/customer/invoice/detail.tsx +36 -12
  115. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  116. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  117. package/src/pages/customer/subscription/detail.tsx +599 -419
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Unit tests for change-payment and change-plan functionality
3
+ * Focus: SlippageConfig normalization and dynamic pricing detection
4
+ */
5
+
6
+ describe('Change Payment/Plan Logic', () => {
7
+ describe('normalizeSlippageConfig (frontend)', () => {
8
+ type SlippageConfigValue = {
9
+ mode: 'percent' | 'rate';
10
+ percent: number;
11
+ min_acceptable_rate?: string;
12
+ base_currency?: string;
13
+ updated_at_ms?: number;
14
+ };
15
+
16
+ const defaultSlippageConfig: SlippageConfigValue = { mode: 'percent', percent: 0.5 };
17
+
18
+ const normalizeSlippageConfig = (rawConfig: any): SlippageConfigValue => {
19
+ if (!rawConfig || typeof rawConfig !== 'object') {
20
+ return defaultSlippageConfig;
21
+ }
22
+ const mode = rawConfig.mode === 'rate' ? 'rate' : 'percent';
23
+ const percentValue = Number(rawConfig.percent);
24
+ const percent = Number.isFinite(percentValue) && percentValue >= 0 ? percentValue : defaultSlippageConfig.percent;
25
+ const minRate = typeof rawConfig.min_acceptable_rate === 'string' ? rawConfig.min_acceptable_rate : undefined;
26
+ const baseCurrency = typeof rawConfig.base_currency === 'string' ? rawConfig.base_currency : undefined;
27
+ const updatedAtMs = Number.isFinite(Number(rawConfig.updated_at_ms)) ? Number(rawConfig.updated_at_ms) : undefined;
28
+
29
+ return {
30
+ mode,
31
+ percent,
32
+ ...(mode === 'rate' && minRate ? { min_acceptable_rate: minRate } : {}),
33
+ ...(baseCurrency ? { base_currency: baseCurrency } : {}),
34
+ ...(updatedAtMs ? { updated_at_ms: updatedAtMs } : {}),
35
+ };
36
+ };
37
+
38
+ it('should return default config for null input', () => {
39
+ const result = normalizeSlippageConfig(null);
40
+ expect(result).toEqual(defaultSlippageConfig);
41
+ });
42
+
43
+ it('should return default config for undefined input', () => {
44
+ const result = normalizeSlippageConfig(undefined);
45
+ expect(result).toEqual(defaultSlippageConfig);
46
+ });
47
+
48
+ it('should return default config for non-object input', () => {
49
+ expect(normalizeSlippageConfig('string')).toEqual(defaultSlippageConfig);
50
+ expect(normalizeSlippageConfig(123)).toEqual(defaultSlippageConfig);
51
+ expect(normalizeSlippageConfig(true)).toEqual(defaultSlippageConfig);
52
+ });
53
+
54
+ it('should normalize percent mode config', () => {
55
+ const rawConfig = {
56
+ mode: 'percent',
57
+ percent: 1.5,
58
+ base_currency: 'USD',
59
+ };
60
+ const result = normalizeSlippageConfig(rawConfig);
61
+ expect(result.mode).toBe('percent');
62
+ expect(result.percent).toBe(1.5);
63
+ expect(result.base_currency).toBe('USD');
64
+ });
65
+
66
+ it('should normalize rate mode config with min_acceptable_rate', () => {
67
+ const rawConfig = {
68
+ mode: 'rate',
69
+ percent: 0.5,
70
+ min_acceptable_rate: '95.5',
71
+ base_currency: 'USD',
72
+ updated_at_ms: 1234567890,
73
+ };
74
+ const result = normalizeSlippageConfig(rawConfig);
75
+ expect(result.mode).toBe('rate');
76
+ expect(result.percent).toBe(0.5);
77
+ expect(result.min_acceptable_rate).toBe('95.5');
78
+ expect(result.base_currency).toBe('USD');
79
+ expect(result.updated_at_ms).toBe(1234567890);
80
+ });
81
+
82
+ it('should default to percent mode for unknown mode', () => {
83
+ const rawConfig = {
84
+ mode: 'unknown',
85
+ percent: 0.5,
86
+ };
87
+ const result = normalizeSlippageConfig(rawConfig);
88
+ expect(result.mode).toBe('percent');
89
+ });
90
+
91
+ it('should use default percent for invalid percent value', () => {
92
+ const rawConfig = {
93
+ mode: 'percent',
94
+ percent: 'invalid',
95
+ };
96
+ const result = normalizeSlippageConfig(rawConfig);
97
+ expect(result.percent).toBe(0.5); // default
98
+ });
99
+
100
+ it('should use default percent for negative percent value', () => {
101
+ const rawConfig = {
102
+ mode: 'percent',
103
+ percent: -1,
104
+ };
105
+ const result = normalizeSlippageConfig(rawConfig);
106
+ expect(result.percent).toBe(0.5); // default
107
+ });
108
+
109
+ it('should accept zero percent', () => {
110
+ const rawConfig = {
111
+ mode: 'percent',
112
+ percent: 0,
113
+ };
114
+ const result = normalizeSlippageConfig(rawConfig);
115
+ expect(result.percent).toBe(0);
116
+ });
117
+
118
+ it('should only include min_acceptable_rate for rate mode', () => {
119
+ const percentConfig = {
120
+ mode: 'percent',
121
+ percent: 0.5,
122
+ min_acceptable_rate: '95.5',
123
+ };
124
+ const result = normalizeSlippageConfig(percentConfig);
125
+ expect(result.min_acceptable_rate).toBeUndefined();
126
+ });
127
+
128
+ it('should handle string percent values', () => {
129
+ const rawConfig = {
130
+ mode: 'percent',
131
+ percent: '1.5',
132
+ };
133
+ const result = normalizeSlippageConfig(rawConfig);
134
+ expect(result.percent).toBe(1.5);
135
+ });
136
+ });
137
+
138
+ describe('Dynamic pricing detection (change-plan)', () => {
139
+ type LineItem = {
140
+ price?: {
141
+ pricing_type?: string;
142
+ };
143
+ upsell_price?: {
144
+ pricing_type?: string;
145
+ };
146
+ };
147
+
148
+ const hasDynamicPricing = (items: LineItem[]): boolean => {
149
+ return (
150
+ items?.length > 0 &&
151
+ items.some((item) => {
152
+ const price = item.upsell_price || item.price;
153
+ return price?.pricing_type === 'dynamic';
154
+ })
155
+ );
156
+ };
157
+
158
+ it('should return false for empty items', () => {
159
+ expect(hasDynamicPricing([])).toBe(false);
160
+ });
161
+
162
+ it('should return false for items without dynamic pricing', () => {
163
+ const items: LineItem[] = [
164
+ { price: { pricing_type: 'fixed' } },
165
+ { price: { pricing_type: 'fixed' } },
166
+ ];
167
+ expect(hasDynamicPricing(items)).toBe(false);
168
+ });
169
+
170
+ it('should return true for items with dynamic pricing', () => {
171
+ const items: LineItem[] = [
172
+ { price: { pricing_type: 'fixed' } },
173
+ { price: { pricing_type: 'dynamic' } },
174
+ ];
175
+ expect(hasDynamicPricing(items)).toBe(true);
176
+ });
177
+
178
+ it('should check upsell_price first if present', () => {
179
+ const items: LineItem[] = [
180
+ {
181
+ price: { pricing_type: 'fixed' },
182
+ upsell_price: { pricing_type: 'dynamic' },
183
+ },
184
+ ];
185
+ expect(hasDynamicPricing(items)).toBe(true);
186
+ });
187
+
188
+ it('should fall back to price if upsell_price is missing', () => {
189
+ const items: LineItem[] = [{ price: { pricing_type: 'dynamic' } }];
190
+ expect(hasDynamicPricing(items)).toBe(true);
191
+ });
192
+
193
+ it('should handle items with no pricing_type', () => {
194
+ const items: LineItem[] = [{ price: {} }, { price: undefined }];
195
+ expect(hasDynamicPricing(items as any)).toBe(false);
196
+ });
197
+ });
198
+
199
+ describe('needsExchangeRate logic', () => {
200
+ type PaymentMethod = {
201
+ type: string;
202
+ };
203
+
204
+ const needsExchangeRate = (hasDynamicPricing: boolean, paymentMethod?: PaymentMethod): boolean => {
205
+ return hasDynamicPricing && paymentMethod?.type !== 'stripe';
206
+ };
207
+
208
+ it('should return true for dynamic pricing with non-stripe payment', () => {
209
+ expect(needsExchangeRate(true, { type: 'arcblock' })).toBe(true);
210
+ expect(needsExchangeRate(true, { type: 'ethereum' })).toBe(true);
211
+ expect(needsExchangeRate(true, { type: 'base' })).toBe(true);
212
+ });
213
+
214
+ it('should return false for dynamic pricing with stripe payment', () => {
215
+ expect(needsExchangeRate(true, { type: 'stripe' })).toBe(false);
216
+ });
217
+
218
+ it('should return false for static pricing', () => {
219
+ expect(needsExchangeRate(false, { type: 'arcblock' })).toBe(false);
220
+ expect(needsExchangeRate(false, { type: 'stripe' })).toBe(false);
221
+ });
222
+
223
+ it('should handle missing payment method', () => {
224
+ expect(needsExchangeRate(true, undefined)).toBe(true);
225
+ });
226
+ });
227
+
228
+ describe('slippage config payload building (change-payment)', () => {
229
+ it('should build payload with base_currency from liveRateInfo', () => {
230
+ const nextConfig: { mode: 'percent'; percent: number; base_currency?: string } = {
231
+ mode: 'percent' as const,
232
+ percent: 0.5,
233
+ };
234
+ const liveRateInfo = {
235
+ base_currency: 'USD',
236
+ rate: '100.5',
237
+ };
238
+
239
+ const payloadConfig = {
240
+ ...nextConfig,
241
+ ...(nextConfig.base_currency ? {} : { base_currency: liveRateInfo?.base_currency || 'USD' }),
242
+ };
243
+
244
+ expect(payloadConfig.base_currency).toBe('USD');
245
+ });
246
+
247
+ it('should preserve existing base_currency in config', () => {
248
+ const nextConfig = {
249
+ mode: 'percent' as const,
250
+ percent: 0.5,
251
+ base_currency: 'EUR',
252
+ };
253
+ const liveRateInfo = {
254
+ base_currency: 'USD',
255
+ };
256
+
257
+ const payloadConfig = {
258
+ ...nextConfig,
259
+ ...(nextConfig.base_currency ? {} : { base_currency: liveRateInfo?.base_currency || 'USD' }),
260
+ };
261
+
262
+ expect(payloadConfig.base_currency).toBe('EUR');
263
+ });
264
+
265
+ it('should default to USD if liveRateInfo is unavailable', () => {
266
+ const nextConfig: { mode: 'percent'; percent: number; base_currency?: string } = {
267
+ mode: 'percent' as const,
268
+ percent: 0.5,
269
+ };
270
+ // Simulate liveRateInfo being undefined
271
+ const liveRateInfo = null as { base_currency?: string } | null;
272
+ const baseCurrencyFallback = liveRateInfo?.base_currency || 'USD';
273
+
274
+ const payloadConfig = {
275
+ ...nextConfig,
276
+ ...(nextConfig.base_currency ? {} : { base_currency: baseCurrencyFallback }),
277
+ };
278
+
279
+ expect(payloadConfig.base_currency).toBe('USD');
280
+ });
281
+ });
282
+ });
@@ -0,0 +1,341 @@
1
+ import { ExchangeRateService, isRateLimitError } from '../../src/libs/exchange-rate/service';
2
+
3
+ describe('isRateLimitError', () => {
4
+ it('should return true for error message containing "rate limit"', () => {
5
+ const error = new Error('CoinGecko rate limit exceeded');
6
+ expect(isRateLimitError(error)).toBe(true);
7
+ });
8
+
9
+ it('should return true for error message containing "Rate Limit" (case insensitive)', () => {
10
+ const error = new Error('Rate Limit Exceeded');
11
+ expect(isRateLimitError(error)).toBe(true);
12
+ });
13
+
14
+ it('should return true for error message containing "429"', () => {
15
+ const error = new Error('Request failed with status code 429');
16
+ expect(isRateLimitError(error)).toBe(true);
17
+ });
18
+
19
+ it('should return false for other error messages', () => {
20
+ const error = new Error('Network timeout');
21
+ expect(isRateLimitError(error)).toBe(false);
22
+ });
23
+
24
+ it('should return false for empty error message', () => {
25
+ const error = new Error('');
26
+ expect(isRateLimitError(error)).toBe(false);
27
+ });
28
+
29
+ it('should return false for error without message', () => {
30
+ const error = new Error();
31
+ expect(isRateLimitError(error)).toBe(false);
32
+ });
33
+ });
34
+
35
+ describe('ExchangeRateService', () => {
36
+ let service: ExchangeRateService;
37
+
38
+ beforeEach(() => {
39
+ service = new ExchangeRateService();
40
+ });
41
+
42
+ describe('CoinGecko Rate Limit State', () => {
43
+ it('should initialize with null rateLimitedUntil', () => {
44
+ expect((service as any).coingeckoRateLimitedUntil).toBeNull();
45
+ });
46
+
47
+ it('should initialize with zero retryCount', () => {
48
+ expect((service as any).coingeckoRetryCount).toBe(0);
49
+ });
50
+
51
+ it('should have correct RATE_LIMIT_INITIAL_DELAY_MS constant (10 minutes)', () => {
52
+ expect((service as any).RATE_LIMIT_INITIAL_DELAY_MS).toBe(10 * 60 * 1000);
53
+ });
54
+
55
+ it('should have correct RATE_LIMIT_MAX_RETRIES constant (5)', () => {
56
+ expect((service as any).RATE_LIMIT_MAX_RETRIES).toBe(5);
57
+ });
58
+
59
+ it('should have correct RATE_LIMIT_BACKOFF_MULTIPLIER constant (2)', () => {
60
+ expect((service as any).RATE_LIMIT_BACKOFF_MULTIPLIER).toBe(2);
61
+ });
62
+ });
63
+
64
+ describe('shouldSkipCoinGecko', () => {
65
+ it('should return false when not rate limited', () => {
66
+ expect((service as any).shouldSkipCoinGecko()).toBe(false);
67
+ });
68
+
69
+ it('should return true when rate limited and time not expired', () => {
70
+ const futureTime = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes in future
71
+ (service as any).coingeckoRateLimitedUntil = futureTime;
72
+
73
+ expect((service as any).shouldSkipCoinGecko()).toBe(true);
74
+ });
75
+
76
+ it('should return false and clear state when rate limit time expired', () => {
77
+ const pastTime = new Date(Date.now() - 1000); // 1 second ago
78
+ (service as any).coingeckoRateLimitedUntil = pastTime;
79
+
80
+ expect((service as any).shouldSkipCoinGecko()).toBe(false);
81
+ expect((service as any).coingeckoRateLimitedUntil).toBeNull();
82
+ });
83
+
84
+ it('should return true when max retries exceeded (provider disabled)', () => {
85
+ (service as any).coingeckoRetryCount = 5; // Max retries reached
86
+ (service as any).coingeckoRateLimitedUntil = null;
87
+
88
+ expect((service as any).shouldSkipCoinGecko()).toBe(true);
89
+ });
90
+ });
91
+
92
+ describe('handleCoinGeckoRateLimit', () => {
93
+ it('should set rateLimitedUntil to 10 minutes from now on first rate limit', () => {
94
+ const beforeTime = Date.now();
95
+ (service as any).handleCoinGeckoRateLimit();
96
+ const afterTime = Date.now();
97
+
98
+ expect((service as any).coingeckoRetryCount).toBe(1);
99
+ expect((service as any).coingeckoRateLimitedUntil).not.toBeNull();
100
+
101
+ const limitedUntil = (service as any).coingeckoRateLimitedUntil.getTime();
102
+ const expectedDelay = 10 * 60 * 1000; // 10 minutes
103
+ expect(limitedUntil).toBeGreaterThanOrEqual(beforeTime + expectedDelay);
104
+ expect(limitedUntil).toBeLessThanOrEqual(afterTime + expectedDelay);
105
+ });
106
+
107
+ it('should use exponential backoff for subsequent rate limits', () => {
108
+ // First rate limit: 10 min
109
+ (service as any).handleCoinGeckoRateLimit();
110
+ expect((service as any).coingeckoRetryCount).toBe(1);
111
+
112
+ // Second rate limit: 20 min
113
+ const beforeSecond = Date.now();
114
+ (service as any).handleCoinGeckoRateLimit();
115
+ expect((service as any).coingeckoRetryCount).toBe(2);
116
+ const secondDelay = (service as any).coingeckoRateLimitedUntil.getTime() - beforeSecond;
117
+ expect(secondDelay).toBeGreaterThanOrEqual(20 * 60 * 1000 - 100); // Allow 100ms tolerance
118
+
119
+ // Third rate limit: 40 min
120
+ const beforeThird = Date.now();
121
+ (service as any).handleCoinGeckoRateLimit();
122
+ expect((service as any).coingeckoRetryCount).toBe(3);
123
+ const thirdDelay = (service as any).coingeckoRateLimitedUntil.getTime() - beforeThird;
124
+ expect(thirdDelay).toBeGreaterThanOrEqual(40 * 60 * 1000 - 100);
125
+ });
126
+
127
+ it('should disable provider after max retries', () => {
128
+ // Exhaust all retries
129
+ for (let i = 0; i < 5; i++) {
130
+ (service as any).handleCoinGeckoRateLimit();
131
+ }
132
+
133
+ expect((service as any).coingeckoRetryCount).toBe(5);
134
+ // Provider should now be disabled (shouldSkipCoinGecko returns true)
135
+ expect((service as any).shouldSkipCoinGecko()).toBe(true);
136
+ });
137
+
138
+ it('should not increment retry count beyond max', () => {
139
+ // Set to max
140
+ (service as any).coingeckoRetryCount = 5;
141
+
142
+ // Try to handle another rate limit
143
+ (service as any).handleCoinGeckoRateLimit();
144
+
145
+ // Should not exceed max
146
+ expect((service as any).coingeckoRetryCount).toBe(5);
147
+ });
148
+ });
149
+
150
+ describe('resetCoinGeckoRateLimitState', () => {
151
+ it('should reset all rate limit state on success', () => {
152
+ // Set up rate limited state
153
+ (service as any).coingeckoRetryCount = 3;
154
+ (service as any).coingeckoRateLimitedUntil = new Date(Date.now() + 60000);
155
+
156
+ // Reset state
157
+ (service as any).resetCoinGeckoRateLimitState();
158
+
159
+ expect((service as any).coingeckoRetryCount).toBe(0);
160
+ expect((service as any).coingeckoRateLimitedUntil).toBeNull();
161
+ });
162
+ });
163
+
164
+ describe('isCoinGeckoProvider', () => {
165
+ it('should return true for coingecko type provider', () => {
166
+ const provider = { type: 'coingecko', name: 'CoinGecko' };
167
+ expect((service as any).isCoinGeckoProvider(provider)).toBe(true);
168
+ });
169
+
170
+ it('should return false for token-data type provider', () => {
171
+ const provider = { type: 'token-data', name: 'TokenData' };
172
+ expect((service as any).isCoinGeckoProvider(provider)).toBe(false);
173
+ });
174
+
175
+ it('should return false for coinmarketcap type provider', () => {
176
+ const provider = { type: 'coinmarketcap', name: 'CoinMarketCap' };
177
+ expect((service as any).isCoinGeckoProvider(provider)).toBe(false);
178
+ });
179
+
180
+ it('should return false for provider without type', () => {
181
+ const provider = { name: 'Unknown' };
182
+ expect((service as any).isCoinGeckoProvider(provider)).toBe(false);
183
+ });
184
+ });
185
+
186
+ describe('validateRate', () => {
187
+ it('should validate positive finite rate', () => {
188
+ const validation = (service as any).validateRate('ABT', '0.15', Date.now());
189
+
190
+ expect(validation.degraded).toBe(false);
191
+ });
192
+
193
+ it('should throw error for negative rate', () => {
194
+ expect(() => {
195
+ (service as any).validateRate('ABT', '-0.15', Date.now());
196
+ }).toThrow('Invalid rate value');
197
+ });
198
+
199
+ it('should throw error for zero rate', () => {
200
+ expect(() => {
201
+ (service as any).validateRate('ABT', '0', Date.now());
202
+ }).toThrow('Invalid rate value');
203
+ });
204
+
205
+ it('should mark as degraded for old timestamp', () => {
206
+ const oldTimestamp = Date.now() - 6 * 60 * 1000; // 6 minutes ago
207
+ const validation = (service as any).validateRate('ABT', '0.15', oldTimestamp);
208
+
209
+ expect(validation.degraded).toBe(true);
210
+ expect(validation.degraded_reason).toContain('old');
211
+ });
212
+
213
+ it('should mark as degraded for large deviation', () => {
214
+ const symbol = 'TEST';
215
+ const now = Date.now();
216
+
217
+ // Build history with consistent rate
218
+ const history = ['1.0', '1.0', '1.0'];
219
+ (service as any).rateHistory.set(symbol, history);
220
+
221
+ // Try rate with >5% deviation
222
+ const validation = (service as any).validateRate(symbol, '1.10', now);
223
+
224
+ expect(validation.degraded).toBe(true);
225
+ expect(validation.degraded_reason).toContain('deviates');
226
+ });
227
+
228
+ it('should not mark as degraded for small deviation', () => {
229
+ const symbol = 'TEST';
230
+ const now = Date.now();
231
+
232
+ // Build history with consistent rate
233
+ const history = ['1.0', '1.0', '1.0'];
234
+ (service as any).rateHistory.set(symbol, history);
235
+
236
+ // Try rate with <5% deviation
237
+ const validation = (service as any).validateRate(symbol, '1.03', now);
238
+
239
+ expect(validation.degraded).toBe(false);
240
+ });
241
+
242
+ it('should update rate history', () => {
243
+ const symbol = 'NEW';
244
+ const now = Date.now();
245
+
246
+ (service as any).validateRate(symbol, '0.15', now);
247
+
248
+ const history = (service as any).rateHistory.get(symbol);
249
+ expect(history).toBeDefined();
250
+ expect(history.length).toBe(1);
251
+ expect(history[0]).toBe('0.15');
252
+ });
253
+
254
+ it('should limit history size to 10 entries', () => {
255
+ const symbol = 'LIMIT';
256
+ const now = Date.now();
257
+
258
+ // Add 12 rates
259
+ for (let i = 0; i < 12; i++) {
260
+ (service as any).validateRate(symbol, '0.15', now);
261
+ }
262
+
263
+ const history = (service as any).rateHistory.get(symbol);
264
+ expect(history.length).toBe(10);
265
+ });
266
+ });
267
+
268
+ describe('clearCache', () => {
269
+ it('should clear specific symbol cache', () => {
270
+ (service as any).cache.set('ABT', {
271
+ data: { rate: '0.15' },
272
+ timestamp: Date.now(),
273
+ });
274
+ (service as any).cache.set('ETH', {
275
+ data: { rate: '2000' },
276
+ timestamp: Date.now(),
277
+ });
278
+
279
+ service.clearCache('ABT');
280
+
281
+ expect((service as any).cache.has('ABT')).toBe(false);
282
+ expect((service as any).cache.has('ETH')).toBe(true);
283
+ });
284
+
285
+ it('should clear all cache when no symbol provided', () => {
286
+ (service as any).cache.set('ABT', {
287
+ data: { rate: '0.15' },
288
+ timestamp: Date.now(),
289
+ });
290
+ (service as any).cache.set('ETH', {
291
+ data: { rate: '2000' },
292
+ timestamp: Date.now(),
293
+ });
294
+
295
+ service.clearCache();
296
+
297
+ expect((service as any).cache.size).toBe(0);
298
+ });
299
+ });
300
+
301
+ describe('Cache behavior', () => {
302
+ it('should cache rate for 30 seconds', () => {
303
+ const cacheKey = 'ABT';
304
+ const cacheEntry = {
305
+ data: {
306
+ rate: '0.15',
307
+ provider_id: 'test',
308
+ provider_name: 'test',
309
+ timestamp_ms: Date.now(),
310
+ degraded: false,
311
+ },
312
+ timestamp: Date.now() - 20000, // 20 seconds ago
313
+ };
314
+
315
+ (service as any).cache.set(cacheKey, cacheEntry);
316
+
317
+ // Cache should still be valid
318
+ const cached = (service as any).cache.get(cacheKey);
319
+ expect(cached).toBeDefined();
320
+ });
321
+
322
+ it('should not use expired cache', () => {
323
+ const cacheKey = 'ABT';
324
+ const cacheEntry = {
325
+ data: {
326
+ rate: '0.15',
327
+ provider_id: 'test',
328
+ provider_name: 'test',
329
+ timestamp_ms: Date.now(),
330
+ degraded: false,
331
+ },
332
+ timestamp: Date.now() - 35000, // 35 seconds ago (expired)
333
+ };
334
+
335
+ (service as any).cache.set(cacheKey, cacheEntry);
336
+
337
+ // Cache exists but is expired, so getRate should try to fetch new data
338
+ // This would require mocking the database, which we test in integration tests
339
+ });
340
+ });
341
+ });