@rooguys/js 0.1.0 → 1.0.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/README.md +342 -141
- package/package.json +1 -1
- package/src/__tests__/fixtures/responses.js +249 -0
- package/src/__tests__/property/batch-event-validation.property.test.js +225 -0
- package/src/__tests__/property/email-validation.property.test.js +272 -0
- package/src/__tests__/property/error-mapping.property.test.js +506 -0
- package/src/__tests__/property/field-selection.property.test.js +297 -0
- package/src/__tests__/property/idempotency-key.property.test.js +350 -0
- package/src/__tests__/property/leaderboard-filter.property.test.js +585 -0
- package/src/__tests__/property/partial-update.property.test.js +251 -0
- package/src/__tests__/property/rate-limit-error.property.test.js +276 -0
- package/src/__tests__/property/rate-limit-extraction.property.test.js +193 -0
- package/src/__tests__/property/request-construction.property.test.js +20 -28
- package/src/__tests__/property/response-format.property.test.js +418 -0
- package/src/__tests__/property/response-parsing.property.test.js +16 -21
- package/src/__tests__/property/timestamp-validation.property.test.js +345 -0
- package/src/__tests__/unit/aha.test.js +57 -26
- package/src/__tests__/unit/config.test.js +7 -1
- package/src/__tests__/unit/errors.test.js +6 -8
- package/src/__tests__/unit/events.test.js +253 -14
- package/src/__tests__/unit/leaderboards.test.js +249 -0
- package/src/__tests__/unit/questionnaires.test.js +6 -6
- package/src/__tests__/unit/users.test.js +275 -12
- package/src/__tests__/utils/generators.js +87 -0
- package/src/__tests__/utils/mockClient.js +71 -5
- package/src/errors.js +156 -0
- package/src/http-client.js +276 -0
- package/src/index.js +856 -66
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Property-Based Test: Partial Update Construction
|
|
3
|
+
* Feature: sdk-documentation-update, Property 8: Partial Update Construction
|
|
4
|
+
* Validates: Requirements 4.6
|
|
5
|
+
*
|
|
6
|
+
* For any user update call with a subset of fields, the request body SHALL contain
|
|
7
|
+
* only the provided fields. Undefined or null fields SHALL NOT be included in the
|
|
8
|
+
* request body.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fc from 'fast-check';
|
|
12
|
+
import { jest } from '@jest/globals';
|
|
13
|
+
import Rooguys from '../../index.js';
|
|
14
|
+
import { createMockFetch, createMockHeaders } from '../utils/mockClient.js';
|
|
15
|
+
|
|
16
|
+
describe('Property 8: Partial Update Construction', () => {
|
|
17
|
+
let mockFetch;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
mockFetch = createMockFetch();
|
|
21
|
+
global.fetch = mockFetch;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
jest.clearAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const createSuccessResponse = (data) => ({
|
|
29
|
+
ok: true,
|
|
30
|
+
headers: createMockHeaders({
|
|
31
|
+
'X-RateLimit-Limit': '1000',
|
|
32
|
+
'X-RateLimit-Remaining': '950',
|
|
33
|
+
'X-RateLimit-Reset': '1704067200',
|
|
34
|
+
}),
|
|
35
|
+
json: async () => ({
|
|
36
|
+
success: true,
|
|
37
|
+
data,
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Generator for valid email
|
|
42
|
+
const validEmail = fc.tuple(
|
|
43
|
+
fc.string({ minLength: 1, maxLength: 20 }).filter(s => /^[a-zA-Z0-9._+-]+$/.test(s) && s.length > 0),
|
|
44
|
+
fc.string({ minLength: 1, maxLength: 15 }).filter(s => /^[a-zA-Z0-9.-]+$/.test(s) && s.length > 0),
|
|
45
|
+
fc.constantFrom('com', 'org', 'net', 'io')
|
|
46
|
+
).map(([local, domain, tld]) => `${local}@${domain}.${tld}`);
|
|
47
|
+
|
|
48
|
+
// Generator for optional user fields
|
|
49
|
+
const optionalUserFields = fc.record({
|
|
50
|
+
displayName: fc.option(fc.string({ minLength: 1, maxLength: 100 }), { nil: undefined }),
|
|
51
|
+
email: fc.option(validEmail, { nil: undefined }),
|
|
52
|
+
firstName: fc.option(fc.string({ minLength: 1, maxLength: 50 }), { nil: undefined }),
|
|
53
|
+
lastName: fc.option(fc.string({ minLength: 1, maxLength: 50 }), { nil: undefined }),
|
|
54
|
+
metadata: fc.option(
|
|
55
|
+
fc.dictionary(
|
|
56
|
+
fc.string({ minLength: 1, maxLength: 20 }),
|
|
57
|
+
fc.oneof(fc.string(), fc.integer(), fc.boolean())
|
|
58
|
+
),
|
|
59
|
+
{ nil: undefined }
|
|
60
|
+
),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should only include provided fields in request body (undefined fields excluded)', async () => {
|
|
64
|
+
await fc.assert(
|
|
65
|
+
fc.asyncProperty(
|
|
66
|
+
fc.string({ minLength: 10, maxLength: 100 }), // API key
|
|
67
|
+
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // User ID
|
|
68
|
+
optionalUserFields,
|
|
69
|
+
async (apiKey, userId, userData) => {
|
|
70
|
+
// Arrange
|
|
71
|
+
mockFetch.mockClear();
|
|
72
|
+
mockFetch.mockResolvedValue(createSuccessResponse({
|
|
73
|
+
user_id: userId,
|
|
74
|
+
...userData,
|
|
75
|
+
}));
|
|
76
|
+
const sdk = new Rooguys(apiKey);
|
|
77
|
+
|
|
78
|
+
// Filter out undefined values to get expected fields
|
|
79
|
+
const providedFields = Object.entries(userData)
|
|
80
|
+
.filter(([_, value]) => value !== undefined)
|
|
81
|
+
.map(([key]) => key);
|
|
82
|
+
|
|
83
|
+
// Skip if no fields provided (nothing to update)
|
|
84
|
+
if (providedFields.length === 0) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Act
|
|
89
|
+
await sdk.users.update(userId, userData);
|
|
90
|
+
|
|
91
|
+
// Assert - parse the request body
|
|
92
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
93
|
+
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
94
|
+
|
|
95
|
+
// Map SDK field names to API field names
|
|
96
|
+
const fieldMapping = {
|
|
97
|
+
displayName: 'display_name',
|
|
98
|
+
email: 'email',
|
|
99
|
+
firstName: 'first_name',
|
|
100
|
+
lastName: 'last_name',
|
|
101
|
+
metadata: 'metadata',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Verify only provided fields are in request body
|
|
105
|
+
const expectedApiFields = providedFields.map(f => fieldMapping[f]);
|
|
106
|
+
const actualFields = Object.keys(callBody);
|
|
107
|
+
|
|
108
|
+
// All actual fields should be expected
|
|
109
|
+
actualFields.forEach(field => {
|
|
110
|
+
expect(expectedApiFields).toContain(field);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// All expected fields should be actual
|
|
114
|
+
expectedApiFields.forEach(field => {
|
|
115
|
+
expect(actualFields).toContain(field);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Verify undefined fields are NOT in request body
|
|
119
|
+
Object.entries(userData).forEach(([key, value]) => {
|
|
120
|
+
const apiField = fieldMapping[key];
|
|
121
|
+
if (value === undefined) {
|
|
122
|
+
expect(callBody[apiField]).toBeUndefined();
|
|
123
|
+
} else {
|
|
124
|
+
expect(callBody[apiField]).toEqual(value);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
),
|
|
129
|
+
{ numRuns: 100 }
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should correctly transform field names from camelCase to snake_case', async () => {
|
|
134
|
+
await fc.assert(
|
|
135
|
+
fc.asyncProperty(
|
|
136
|
+
fc.string({ minLength: 10, maxLength: 100 }), // API key
|
|
137
|
+
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // User ID
|
|
138
|
+
fc.string({ minLength: 1, maxLength: 100 }), // Display name
|
|
139
|
+
async (apiKey, userId, displayName) => {
|
|
140
|
+
// Arrange
|
|
141
|
+
mockFetch.mockClear();
|
|
142
|
+
mockFetch.mockResolvedValue(createSuccessResponse({
|
|
143
|
+
user_id: userId,
|
|
144
|
+
display_name: displayName,
|
|
145
|
+
}));
|
|
146
|
+
const sdk = new Rooguys(apiKey);
|
|
147
|
+
|
|
148
|
+
// Act
|
|
149
|
+
await sdk.users.update(userId, { displayName });
|
|
150
|
+
|
|
151
|
+
// Assert
|
|
152
|
+
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
153
|
+
expect(callBody.display_name).toBe(displayName);
|
|
154
|
+
expect(callBody.displayName).toBeUndefined(); // camelCase should not be present
|
|
155
|
+
}
|
|
156
|
+
),
|
|
157
|
+
{ numRuns: 100 }
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should handle single field updates correctly', async () => {
|
|
162
|
+
await fc.assert(
|
|
163
|
+
fc.asyncProperty(
|
|
164
|
+
fc.string({ minLength: 10, maxLength: 100 }), // API key
|
|
165
|
+
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // User ID
|
|
166
|
+
fc.constantFrom('displayName', 'firstName', 'lastName'),
|
|
167
|
+
fc.string({ minLength: 1, maxLength: 100 }),
|
|
168
|
+
async (apiKey, userId, fieldName, fieldValue) => {
|
|
169
|
+
// Arrange
|
|
170
|
+
mockFetch.mockClear();
|
|
171
|
+
mockFetch.mockResolvedValue(createSuccessResponse({
|
|
172
|
+
user_id: userId,
|
|
173
|
+
}));
|
|
174
|
+
const sdk = new Rooguys(apiKey);
|
|
175
|
+
|
|
176
|
+
// Act
|
|
177
|
+
await sdk.users.update(userId, { [fieldName]: fieldValue });
|
|
178
|
+
|
|
179
|
+
// Assert - only one field in body
|
|
180
|
+
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
181
|
+
const bodyKeys = Object.keys(callBody);
|
|
182
|
+
expect(bodyKeys).toHaveLength(1);
|
|
183
|
+
}
|
|
184
|
+
),
|
|
185
|
+
{ numRuns: 100 }
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should handle metadata updates with nested objects', async () => {
|
|
190
|
+
await fc.assert(
|
|
191
|
+
fc.asyncProperty(
|
|
192
|
+
fc.string({ minLength: 10, maxLength: 100 }), // API key
|
|
193
|
+
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // User ID
|
|
194
|
+
fc.dictionary(
|
|
195
|
+
fc.string({ minLength: 1, maxLength: 20 }),
|
|
196
|
+
fc.oneof(
|
|
197
|
+
fc.string(),
|
|
198
|
+
fc.integer(),
|
|
199
|
+
fc.boolean(),
|
|
200
|
+
fc.constant(null)
|
|
201
|
+
)
|
|
202
|
+
),
|
|
203
|
+
async (apiKey, userId, metadata) => {
|
|
204
|
+
// Arrange
|
|
205
|
+
mockFetch.mockClear();
|
|
206
|
+
mockFetch.mockResolvedValue(createSuccessResponse({
|
|
207
|
+
user_id: userId,
|
|
208
|
+
metadata,
|
|
209
|
+
}));
|
|
210
|
+
const sdk = new Rooguys(apiKey);
|
|
211
|
+
|
|
212
|
+
// Act
|
|
213
|
+
await sdk.users.update(userId, { metadata });
|
|
214
|
+
|
|
215
|
+
// Assert
|
|
216
|
+
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
217
|
+
expect(callBody.metadata).toEqual(metadata);
|
|
218
|
+
expect(Object.keys(callBody)).toHaveLength(1);
|
|
219
|
+
}
|
|
220
|
+
),
|
|
221
|
+
{ numRuns: 100 }
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should preserve exact values without modification', async () => {
|
|
226
|
+
await fc.assert(
|
|
227
|
+
fc.asyncProperty(
|
|
228
|
+
fc.string({ minLength: 10, maxLength: 100 }), // API key
|
|
229
|
+
fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // User ID
|
|
230
|
+
fc.string({ minLength: 1, maxLength: 100 }), // Display name with special chars
|
|
231
|
+
async (apiKey, userId, displayName) => {
|
|
232
|
+
// Arrange
|
|
233
|
+
mockFetch.mockClear();
|
|
234
|
+
mockFetch.mockResolvedValue(createSuccessResponse({
|
|
235
|
+
user_id: userId,
|
|
236
|
+
display_name: displayName,
|
|
237
|
+
}));
|
|
238
|
+
const sdk = new Rooguys(apiKey);
|
|
239
|
+
|
|
240
|
+
// Act
|
|
241
|
+
await sdk.users.update(userId, { displayName });
|
|
242
|
+
|
|
243
|
+
// Assert - value should be exactly preserved
|
|
244
|
+
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
245
|
+
expect(callBody.display_name).toBe(displayName);
|
|
246
|
+
}
|
|
247
|
+
),
|
|
248
|
+
{ numRuns: 100 }
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Property-Based Test: Rate Limit Error Handling
|
|
3
|
+
* Feature: sdk-documentation-update, Property 12: Rate Limit Error Handling
|
|
4
|
+
* Validates: Requirements 7.2
|
|
5
|
+
*
|
|
6
|
+
* For any API response with HTTP status 429, the SDK SHALL throw a RateLimitError
|
|
7
|
+
* with `retryAfter` property set to the value from the `Retry-After` header
|
|
8
|
+
* (or calculated from `X-RateLimit-Reset`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fc from 'fast-check';
|
|
12
|
+
import { jest } from '@jest/globals';
|
|
13
|
+
import Rooguys from '../../index.js';
|
|
14
|
+
import { RateLimitError } from '../../errors.js';
|
|
15
|
+
import { createMockFetch, createMockHeaders } from '../utils/mockClient.js';
|
|
16
|
+
|
|
17
|
+
describe('Property 12: Rate Limit Error Handling', () => {
|
|
18
|
+
let mockFetch;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
mockFetch = createMockFetch();
|
|
22
|
+
global.fetch = mockFetch;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
jest.clearAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const createRateLimitResponse = (retryAfter, headers = {}) => ({
|
|
30
|
+
ok: false,
|
|
31
|
+
status: 429,
|
|
32
|
+
headers: createMockHeaders({
|
|
33
|
+
'Retry-After': String(retryAfter),
|
|
34
|
+
'X-RateLimit-Limit': '1000',
|
|
35
|
+
'X-RateLimit-Remaining': '0',
|
|
36
|
+
'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + retryAfter),
|
|
37
|
+
'X-Request-Id': 'req_ratelimit123',
|
|
38
|
+
...headers,
|
|
39
|
+
}),
|
|
40
|
+
json: async () => ({
|
|
41
|
+
success: false,
|
|
42
|
+
error: {
|
|
43
|
+
code: 'RATE_LIMIT_EXCEEDED',
|
|
44
|
+
message: 'Rate limit exceeded. Please retry later.',
|
|
45
|
+
},
|
|
46
|
+
request_id: 'req_ratelimit123',
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should throw RateLimitError with correct retryAfter from Retry-After header', async () => {
|
|
51
|
+
await fc.assert(
|
|
52
|
+
fc.asyncProperty(
|
|
53
|
+
fc.string({ minLength: 10, maxLength: 100 }),
|
|
54
|
+
fc.integer({ min: 1, max: 3600 }), // retryAfter in seconds (1 second to 1 hour)
|
|
55
|
+
async (apiKey, retryAfter) => {
|
|
56
|
+
// Arrange
|
|
57
|
+
mockFetch.mockClear();
|
|
58
|
+
mockFetch.mockResolvedValue(createRateLimitResponse(retryAfter));
|
|
59
|
+
const sdk = new Rooguys(apiKey);
|
|
60
|
+
|
|
61
|
+
// Act & Assert
|
|
62
|
+
try {
|
|
63
|
+
await sdk.users.get('test-user');
|
|
64
|
+
// Should not reach here
|
|
65
|
+
expect(true).toBe(false);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
// Assert - Should be RateLimitError with correct retryAfter
|
|
68
|
+
expect(error).toBeInstanceOf(RateLimitError);
|
|
69
|
+
expect(error.name).toBe('RateLimitError');
|
|
70
|
+
expect(error.statusCode).toBe(429);
|
|
71
|
+
expect(error.retryAfter).toBe(retryAfter);
|
|
72
|
+
expect(typeof error.retryAfter).toBe('number');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
),
|
|
76
|
+
{ numRuns: 100 }
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should include requestId in RateLimitError', async () => {
|
|
81
|
+
await fc.assert(
|
|
82
|
+
fc.asyncProperty(
|
|
83
|
+
fc.string({ minLength: 10, maxLength: 100 }),
|
|
84
|
+
fc.integer({ min: 1, max: 3600 }),
|
|
85
|
+
fc.string({ minLength: 5, maxLength: 50 }).map(s => `req_${s}`),
|
|
86
|
+
async (apiKey, retryAfter, requestId) => {
|
|
87
|
+
// Arrange
|
|
88
|
+
mockFetch.mockClear();
|
|
89
|
+
mockFetch.mockResolvedValue({
|
|
90
|
+
ok: false,
|
|
91
|
+
status: 429,
|
|
92
|
+
headers: createMockHeaders({
|
|
93
|
+
'Retry-After': String(retryAfter),
|
|
94
|
+
'X-Request-Id': requestId,
|
|
95
|
+
'X-RateLimit-Limit': '1000',
|
|
96
|
+
'X-RateLimit-Remaining': '0',
|
|
97
|
+
}),
|
|
98
|
+
json: async () => ({
|
|
99
|
+
success: false,
|
|
100
|
+
error: {
|
|
101
|
+
code: 'RATE_LIMIT_EXCEEDED',
|
|
102
|
+
message: 'Rate limit exceeded',
|
|
103
|
+
},
|
|
104
|
+
request_id: requestId,
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
const sdk = new Rooguys(apiKey);
|
|
108
|
+
|
|
109
|
+
// Act & Assert
|
|
110
|
+
try {
|
|
111
|
+
await sdk.events.track('test-event', 'user123');
|
|
112
|
+
expect(true).toBe(false);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
expect(error).toBeInstanceOf(RateLimitError);
|
|
115
|
+
expect(error.requestId).toBe(requestId);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
),
|
|
119
|
+
{ numRuns: 100 }
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should use default retryAfter when Retry-After header is missing', async () => {
|
|
124
|
+
await fc.assert(
|
|
125
|
+
fc.asyncProperty(
|
|
126
|
+
fc.string({ minLength: 10, maxLength: 100 }),
|
|
127
|
+
async (apiKey) => {
|
|
128
|
+
// Arrange - Response without Retry-After header
|
|
129
|
+
mockFetch.mockClear();
|
|
130
|
+
mockFetch.mockResolvedValue({
|
|
131
|
+
ok: false,
|
|
132
|
+
status: 429,
|
|
133
|
+
headers: createMockHeaders({
|
|
134
|
+
'X-RateLimit-Limit': '1000',
|
|
135
|
+
'X-RateLimit-Remaining': '0',
|
|
136
|
+
}),
|
|
137
|
+
json: async () => ({
|
|
138
|
+
success: false,
|
|
139
|
+
error: {
|
|
140
|
+
code: 'RATE_LIMIT_EXCEEDED',
|
|
141
|
+
message: 'Rate limit exceeded',
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
const sdk = new Rooguys(apiKey);
|
|
146
|
+
|
|
147
|
+
// Act & Assert
|
|
148
|
+
try {
|
|
149
|
+
await sdk.leaderboards.getGlobal();
|
|
150
|
+
expect(true).toBe(false);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
expect(error).toBeInstanceOf(RateLimitError);
|
|
153
|
+
// Default retryAfter should be 60 seconds
|
|
154
|
+
expect(error.retryAfter).toBe(60);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
),
|
|
158
|
+
{ numRuns: 100 }
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should include error code in RateLimitError', async () => {
|
|
163
|
+
await fc.assert(
|
|
164
|
+
fc.asyncProperty(
|
|
165
|
+
fc.string({ minLength: 10, maxLength: 100 }),
|
|
166
|
+
fc.integer({ min: 1, max: 3600 }),
|
|
167
|
+
fc.constantFrom('RATE_LIMIT_EXCEEDED', 'TOO_MANY_REQUESTS', 'QUOTA_EXCEEDED'),
|
|
168
|
+
async (apiKey, retryAfter, errorCode) => {
|
|
169
|
+
// Arrange
|
|
170
|
+
mockFetch.mockClear();
|
|
171
|
+
mockFetch.mockResolvedValue({
|
|
172
|
+
ok: false,
|
|
173
|
+
status: 429,
|
|
174
|
+
headers: createMockHeaders({
|
|
175
|
+
'Retry-After': String(retryAfter),
|
|
176
|
+
'X-RateLimit-Limit': '1000',
|
|
177
|
+
'X-RateLimit-Remaining': '0',
|
|
178
|
+
}),
|
|
179
|
+
json: async () => ({
|
|
180
|
+
success: false,
|
|
181
|
+
error: {
|
|
182
|
+
code: errorCode,
|
|
183
|
+
message: 'Rate limit exceeded',
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
187
|
+
const sdk = new Rooguys(apiKey);
|
|
188
|
+
|
|
189
|
+
// Act & Assert
|
|
190
|
+
try {
|
|
191
|
+
await sdk.badges.list();
|
|
192
|
+
expect(true).toBe(false);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
expect(error).toBeInstanceOf(RateLimitError);
|
|
195
|
+
expect(error.code).toBe(errorCode);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
),
|
|
199
|
+
{ numRuns: 100 }
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should serialize RateLimitError to JSON with retryAfter', async () => {
|
|
204
|
+
await fc.assert(
|
|
205
|
+
fc.asyncProperty(
|
|
206
|
+
fc.string({ minLength: 10, maxLength: 100 }),
|
|
207
|
+
fc.integer({ min: 1, max: 3600 }),
|
|
208
|
+
async (apiKey, retryAfter) => {
|
|
209
|
+
// Arrange
|
|
210
|
+
mockFetch.mockClear();
|
|
211
|
+
mockFetch.mockResolvedValue(createRateLimitResponse(retryAfter));
|
|
212
|
+
const sdk = new Rooguys(apiKey);
|
|
213
|
+
|
|
214
|
+
// Act & Assert
|
|
215
|
+
try {
|
|
216
|
+
await sdk.levels.list();
|
|
217
|
+
expect(true).toBe(false);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
expect(error).toBeInstanceOf(RateLimitError);
|
|
220
|
+
|
|
221
|
+
// Test JSON serialization
|
|
222
|
+
const json = error.toJSON();
|
|
223
|
+
expect(json.name).toBe('RateLimitError');
|
|
224
|
+
expect(json.statusCode).toBe(429);
|
|
225
|
+
expect(json.retryAfter).toBe(retryAfter);
|
|
226
|
+
expect(typeof json.retryAfter).toBe('number');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
),
|
|
230
|
+
{ numRuns: 100 }
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should throw RateLimitError for all SDK methods on 429', async () => {
|
|
235
|
+
await fc.assert(
|
|
236
|
+
fc.asyncProperty(
|
|
237
|
+
fc.string({ minLength: 10, maxLength: 100 }),
|
|
238
|
+
fc.integer({ min: 1, max: 3600 }),
|
|
239
|
+
fc.constantFrom('users.get', 'events.track', 'leaderboards.getGlobal', 'badges.list', 'levels.list'),
|
|
240
|
+
async (apiKey, retryAfter, methodName) => {
|
|
241
|
+
// Arrange
|
|
242
|
+
mockFetch.mockClear();
|
|
243
|
+
mockFetch.mockResolvedValue(createRateLimitResponse(retryAfter));
|
|
244
|
+
const sdk = new Rooguys(apiKey);
|
|
245
|
+
|
|
246
|
+
// Act & Assert
|
|
247
|
+
try {
|
|
248
|
+
switch (methodName) {
|
|
249
|
+
case 'users.get':
|
|
250
|
+
await sdk.users.get('test-user');
|
|
251
|
+
break;
|
|
252
|
+
case 'events.track':
|
|
253
|
+
await sdk.events.track('test-event', 'user123');
|
|
254
|
+
break;
|
|
255
|
+
case 'leaderboards.getGlobal':
|
|
256
|
+
await sdk.leaderboards.getGlobal();
|
|
257
|
+
break;
|
|
258
|
+
case 'badges.list':
|
|
259
|
+
await sdk.badges.list();
|
|
260
|
+
break;
|
|
261
|
+
case 'levels.list':
|
|
262
|
+
await sdk.levels.list();
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
expect(true).toBe(false);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
expect(error).toBeInstanceOf(RateLimitError);
|
|
268
|
+
expect(error.statusCode).toBe(429);
|
|
269
|
+
expect(error.retryAfter).toBe(retryAfter);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
),
|
|
273
|
+
{ numRuns: 100 }
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
});
|