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.
- package/api/src/crons/overdue-detection.ts +10 -1
- package/api/src/index.ts +3 -0
- package/api/src/libs/credit-utils.ts +21 -0
- package/api/src/libs/discount/discount.ts +13 -0
- package/api/src/libs/env.ts +5 -0
- package/api/src/libs/error.ts +14 -0
- package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
- package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
- package/api/src/libs/exchange-rate/index.ts +5 -0
- package/api/src/libs/exchange-rate/service.ts +583 -0
- package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
- package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
- package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
- package/api/src/libs/exchange-rate/types.ts +114 -0
- package/api/src/libs/exchange-rate/validator.ts +319 -0
- package/api/src/libs/invoice-quote.ts +158 -0
- package/api/src/libs/invoice.ts +143 -7
- package/api/src/libs/math-utils.ts +46 -0
- package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
- package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
- package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
- package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
- package/api/src/libs/payment.ts +1 -1
- package/api/src/libs/price.ts +4 -1
- package/api/src/libs/queue/index.ts +8 -0
- package/api/src/libs/quote-service.ts +1132 -0
- package/api/src/libs/quote-validation.ts +388 -0
- package/api/src/libs/session.ts +686 -39
- package/api/src/libs/slippage.ts +135 -0
- package/api/src/libs/subscription.ts +185 -15
- package/api/src/libs/util.ts +64 -3
- package/api/src/locales/en.ts +50 -0
- package/api/src/locales/zh.ts +48 -0
- package/api/src/queues/auto-recharge.ts +295 -21
- package/api/src/queues/exchange-rate-health.ts +242 -0
- package/api/src/queues/invoice.ts +48 -1
- package/api/src/queues/notification.ts +190 -3
- package/api/src/queues/payment.ts +177 -7
- package/api/src/queues/subscription.ts +436 -6
- package/api/src/routes/auto-recharge-configs.ts +71 -6
- package/api/src/routes/checkout-sessions.ts +1730 -81
- package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
- package/api/src/routes/connect/change-payer.ts +2 -0
- package/api/src/routes/connect/change-payment.ts +61 -8
- package/api/src/routes/connect/change-plan.ts +161 -17
- package/api/src/routes/connect/collect.ts +9 -6
- package/api/src/routes/connect/delegation.ts +1 -0
- package/api/src/routes/connect/pay.ts +157 -0
- package/api/src/routes/connect/setup.ts +32 -10
- package/api/src/routes/connect/shared.ts +159 -13
- package/api/src/routes/connect/subscribe.ts +32 -9
- package/api/src/routes/credit-grants.ts +99 -0
- package/api/src/routes/exchange-rate-providers.ts +248 -0
- package/api/src/routes/exchange-rates.ts +87 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +280 -2
- package/api/src/routes/meter-events.ts +3 -0
- package/api/src/routes/payment-links.ts +13 -0
- package/api/src/routes/prices.ts +84 -2
- package/api/src/routes/subscriptions.ts +526 -15
- package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
- package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
- package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
- package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
- package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
- package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
- package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
- package/api/src/store/models/auto-recharge-config.ts +12 -0
- package/api/src/store/models/checkout-session.ts +7 -0
- package/api/src/store/models/exchange-rate-provider.ts +225 -0
- package/api/src/store/models/index.ts +6 -0
- package/api/src/store/models/payment-intent.ts +6 -0
- package/api/src/store/models/price-quote.ts +284 -0
- package/api/src/store/models/price.ts +53 -5
- package/api/src/store/models/subscription.ts +11 -0
- package/api/src/store/models/types.ts +61 -1
- package/api/tests/libs/change-payment-plan.spec.ts +282 -0
- package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
- package/api/tests/libs/quote-service.spec.ts +199 -0
- package/api/tests/libs/session.spec.ts +464 -0
- package/api/tests/libs/slippage.spec.ts +109 -0
- package/api/tests/libs/token-data-provider.spec.ts +267 -0
- package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
- package/api/tests/models/price-dynamic.spec.ts +100 -0
- package/api/tests/models/price-quote.spec.ts +112 -0
- package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
- package/api/tests/routes/subscription-slippage.spec.ts +254 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -6
- package/src/components/customer/credit-overview.tsx +14 -0
- package/src/components/discount/discount-info.tsx +8 -2
- package/src/components/invoice/list.tsx +146 -16
- package/src/components/invoice/table.tsx +276 -71
- package/src/components/invoice-pdf/template.tsx +3 -7
- package/src/components/metadata/form.tsx +6 -8
- package/src/components/price/form.tsx +519 -149
- package/src/components/promotion/active-redemptions.tsx +5 -3
- package/src/components/quote/info.tsx +234 -0
- package/src/hooks/subscription.ts +132 -2
- package/src/locales/en.tsx +145 -0
- package/src/locales/zh.tsx +143 -1
- package/src/pages/admin/billing/invoices/detail.tsx +41 -4
- package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
- package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
- package/src/pages/admin/products/index.tsx +12 -1
- package/src/pages/customer/invoice/detail.tsx +36 -12
- package/src/pages/customer/subscription/change-payment.tsx +65 -3
- package/src/pages/customer/subscription/change-plan.tsx +207 -38
- 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
|
+
});
|