Skip to content

Commit 4b6576e

Browse files
committed
feat: throttle testnet faucet proxy calls (#570)
1 parent ba7f72f commit 4b6576e

2 files changed

Lines changed: 463 additions & 0 deletions

File tree

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { ConfigService } from '@nestjs/config';
3+
import { TooManyRequestsException } from '@nestjs/common';
4+
import { TestnetFaucetService, FaucetRequest } from './testnet-faucet.service';
5+
6+
describe('TestnetFaucetService', () => {
7+
let service: TestnetFaucetService;
8+
let configService: any;
9+
10+
beforeEach(async () => {
11+
configService = {
12+
get: jest.fn((key: string, defaultValue: any) => {
13+
const config: Record<string, any> = {
14+
TESTNET_FAUCET_MAX_REQUESTS: 3,
15+
TESTNET_FAUCET_WINDOW_MS: 3600000, // 1 hour
16+
TESTNET_FAUCET_URL: 'https://faucet.testnet.example.com',
17+
};
18+
return config[key] ?? defaultValue;
19+
}),
20+
};
21+
22+
const module: TestingModule = await Test.createTestingModule({
23+
providers: [
24+
TestnetFaucetService,
25+
{ provide: ConfigService, useValue: configService },
26+
],
27+
}).compile();
28+
29+
service = module.get<TestnetFaucetService>(TestnetFaucetService);
30+
});
31+
32+
it('should be defined', () => {
33+
expect(service).toBeDefined();
34+
});
35+
36+
describe('requestFunds', () => {
37+
it('should successfully request funds for new wallet', async () => {
38+
const request: FaucetRequest = {
39+
walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
40+
network: 'TESTNET',
41+
requestedAmount: 100,
42+
};
43+
44+
const response = await service.requestFunds(request);
45+
46+
expect(response).toBeDefined();
47+
expect(response.walletAddress).toBe(request.walletAddress);
48+
expect(response.amountSent).toBe(100);
49+
expect(response.transactionId).toBeDefined();
50+
expect(response.timestamp).toBeInstanceOf(Date);
51+
});
52+
53+
it('should use default amount when not specified', async () => {
54+
const request: FaucetRequest = {
55+
walletAddress: 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
56+
network: 'TESTNET',
57+
};
58+
59+
const response = await service.requestFunds(request);
60+
61+
expect(response.amountSent).toBe(100); // default
62+
});
63+
64+
it('should allow multiple requests within throttle window', async () => {
65+
const walletAddress = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
66+
67+
const request1 = await service.requestFunds({
68+
walletAddress,
69+
network: 'TESTNET',
70+
});
71+
const request2 = await service.requestFunds({
72+
walletAddress,
73+
network: 'TESTNET',
74+
});
75+
const request3 = await service.requestFunds({
76+
walletAddress,
77+
network: 'TESTNET',
78+
});
79+
80+
expect(request1.transactionId).toBeDefined();
81+
expect(request2.transactionId).toBeDefined();
82+
expect(request3.transactionId).toBeDefined();
83+
});
84+
85+
it('should throw TooManyRequestsException when throttle limit exceeded', async () => {
86+
const walletAddress = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
87+
const request: FaucetRequest = {
88+
walletAddress,
89+
network: 'TESTNET',
90+
};
91+
92+
await service.requestFunds(request);
93+
await service.requestFunds(request);
94+
await service.requestFunds(request);
95+
96+
// Fourth request should fail
97+
await expect(service.requestFunds(request)).rejects.toThrow(
98+
TooManyRequestsException,
99+
);
100+
});
101+
102+
it('should include retry information in throttle error', async () => {
103+
const walletAddress = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
104+
const request: FaucetRequest = {
105+
walletAddress,
106+
network: 'TESTNET',
107+
};
108+
109+
await service.requestFunds(request);
110+
await service.requestFunds(request);
111+
await service.requestFunds(request);
112+
113+
try {
114+
await service.requestFunds(request);
115+
fail('Should have thrown TooManyRequestsException');
116+
} catch (error) {
117+
expect(error).toBeInstanceOf(TooManyRequestsException);
118+
expect((error as any).message).toContain('Retry after');
119+
}
120+
});
121+
});
122+
123+
describe('throttle tracking', () => {
124+
it('should track separate throttle entries per wallet', async () => {
125+
const wallet1 = 'GWALLET1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
126+
const wallet2 = 'GWALLET2XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
127+
128+
await service.requestFunds({ walletAddress: wallet1, network: 'TESTNET' });
129+
await service.requestFunds({ walletAddress: wallet2, network: 'TESTNET' });
130+
131+
const info1 = service.getThrottleInfo(wallet1);
132+
const info2 = service.getThrottleInfo(wallet2);
133+
134+
expect(info1?.count).toBe(1);
135+
expect(info2?.count).toBe(1);
136+
});
137+
138+
it('should reset throttle after window expires', async () => {
139+
const walletAddress = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
140+
const request: FaucetRequest = {
141+
walletAddress,
142+
network: 'TESTNET',
143+
};
144+
145+
await service.requestFunds(request);
146+
const info1 = service.getThrottleInfo(walletAddress);
147+
expect(info1?.count).toBe(1);
148+
149+
// Clear and recreate service with very short window for testing
150+
service.clearThrottleEntry(walletAddress);
151+
152+
const info2 = service.getThrottleInfo(walletAddress);
153+
expect(info2).toBeUndefined();
154+
});
155+
});
156+
157+
describe('getThrottleInfo', () => {
158+
it('should return throttle info for active wallet', async () => {
159+
const walletAddress = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
160+
161+
await service.requestFunds({
162+
walletAddress,
163+
network: 'TESTNET',
164+
});
165+
166+
const info = service.getThrottleInfo(walletAddress);
167+
168+
expect(info).toBeDefined();
169+
expect(info?.count).toBe(1);
170+
expect(info?.firstRequestAt).toBeInstanceOf(Date);
171+
expect(info?.lastRequestAt).toBeInstanceOf(Date);
172+
});
173+
174+
it('should return undefined for unknown wallet', () => {
175+
const info = service.getThrottleInfo('GUNKNOWN');
176+
expect(info).toBeUndefined();
177+
});
178+
});
179+
180+
describe('clearThrottleEntry', () => {
181+
it('should clear throttle entry for specific wallet', async () => {
182+
const walletAddress = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
183+
184+
await service.requestFunds({
185+
walletAddress,
186+
network: 'TESTNET',
187+
});
188+
189+
let info = service.getThrottleInfo(walletAddress);
190+
expect(info).toBeDefined();
191+
192+
service.clearThrottleEntry(walletAddress);
193+
194+
info = service.getThrottleInfo(walletAddress);
195+
expect(info).toBeUndefined();
196+
});
197+
});
198+
199+
describe('clearAllThrottleEntries', () => {
200+
it('should clear all throttle entries', async () => {
201+
await service.requestFunds({
202+
walletAddress: 'GWALLET1',
203+
network: 'TESTNET',
204+
});
205+
await service.requestFunds({
206+
walletAddress: 'GWALLET2',
207+
network: 'TESTNET',
208+
});
209+
210+
let entries = service.getAllThrottleEntries();
211+
expect(entries.size).toBe(2);
212+
213+
service.clearAllThrottleEntries();
214+
215+
entries = service.getAllThrottleEntries();
216+
expect(entries.size).toBe(0);
217+
});
218+
});
219+
220+
describe('throttle window behavior', () => {
221+
it('should enforce max requests per window', async () => {
222+
const walletAddress = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
223+
const maxRequests = 3;
224+
225+
// Make max requests
226+
for (let i = 0; i < maxRequests; i++) {
227+
const response = await service.requestFunds({
228+
walletAddress,
229+
network: 'TESTNET',
230+
});
231+
expect(response.transactionId).toBeDefined();
232+
}
233+
234+
// Next request should fail
235+
await expect(
236+
service.requestFunds({
237+
walletAddress,
238+
network: 'TESTNET',
239+
}),
240+
).rejects.toThrow(TooManyRequestsException);
241+
});
242+
243+
it('should track first and last request times', async () => {
244+
const walletAddress = 'GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
245+
246+
const first = new Date();
247+
await service.requestFunds({
248+
walletAddress,
249+
network: 'TESTNET',
250+
});
251+
252+
// Small delay
253+
await new Promise((resolve) => setTimeout(resolve, 10));
254+
255+
await service.requestFunds({
256+
walletAddress,
257+
network: 'TESTNET',
258+
});
259+
const last = new Date();
260+
261+
const info = service.getThrottleInfo(walletAddress);
262+
expect(info?.firstRequestAt.getTime()).toBeLessThanOrEqual(
263+
first.getTime(),
264+
);
265+
expect(info?.lastRequestAt.getTime()).toBeGreaterThanOrEqual(
266+
last.getTime(),
267+
);
268+
});
269+
});
270+
});

0 commit comments

Comments
 (0)