@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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import Rooguys from '../../index';
|
|
2
|
-
import {
|
|
1
|
+
import Rooguys, { ValidationError } from '../../index';
|
|
2
|
+
import { mockStandardizedSuccess, mockErrorResponse } from '../utils/mockClient';
|
|
3
3
|
import { mockResponses, mockErrors } from '../fixtures/responses';
|
|
4
4
|
|
|
5
5
|
describe('Users Resource', () => {
|
|
@@ -11,17 +11,171 @@ describe('Users Resource', () => {
|
|
|
11
11
|
global.fetch.mockClear();
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
+
describe('create', () => {
|
|
15
|
+
it('should create a new user', async () => {
|
|
16
|
+
const userData = {
|
|
17
|
+
userId: 'new_user_123',
|
|
18
|
+
displayName: 'Test User',
|
|
19
|
+
email: 'test@example.com',
|
|
20
|
+
};
|
|
21
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess({
|
|
22
|
+
user_id: 'new_user_123',
|
|
23
|
+
display_name: 'Test User',
|
|
24
|
+
email: 'test@example.com',
|
|
25
|
+
points: 0,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const result = await client.users.create(userData);
|
|
29
|
+
|
|
30
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
31
|
+
expect.stringContaining('/users'),
|
|
32
|
+
expect.objectContaining({
|
|
33
|
+
method: 'POST',
|
|
34
|
+
body: expect.stringContaining('new_user_123'),
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
expect(result.user_id).toBe('new_user_123');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should throw ValidationError when userId is missing', async () => {
|
|
41
|
+
await expect(client.users.create({})).rejects.toThrow(ValidationError);
|
|
42
|
+
await expect(client.users.create({})).rejects.toThrow('User ID is required');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should throw ValidationError for invalid email format', async () => {
|
|
46
|
+
await expect(client.users.create({
|
|
47
|
+
userId: 'user_123',
|
|
48
|
+
email: 'invalid-email',
|
|
49
|
+
})).rejects.toThrow(ValidationError);
|
|
50
|
+
await expect(client.users.create({
|
|
51
|
+
userId: 'user_123',
|
|
52
|
+
email: 'invalid-email',
|
|
53
|
+
})).rejects.toThrow('Invalid email format');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should allow creation without email', async () => {
|
|
57
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess({
|
|
58
|
+
user_id: 'user_123',
|
|
59
|
+
points: 0,
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
const result = await client.users.create({ userId: 'user_123' });
|
|
63
|
+
expect(result.user_id).toBe('user_123');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('update', () => {
|
|
68
|
+
it('should update an existing user', async () => {
|
|
69
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess({
|
|
70
|
+
user_id: 'user_123',
|
|
71
|
+
display_name: 'Updated Name',
|
|
72
|
+
points: 100,
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
const result = await client.users.update('user_123', { displayName: 'Updated Name' });
|
|
76
|
+
|
|
77
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
78
|
+
expect.stringContaining('/users/user_123'),
|
|
79
|
+
expect.objectContaining({
|
|
80
|
+
method: 'PATCH',
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
expect(result.display_name).toBe('Updated Name');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should throw ValidationError when userId is missing', async () => {
|
|
87
|
+
await expect(client.users.update('', { displayName: 'Test' })).rejects.toThrow(ValidationError);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should throw ValidationError for invalid email format', async () => {
|
|
91
|
+
await expect(client.users.update('user_123', {
|
|
92
|
+
email: 'not-an-email',
|
|
93
|
+
})).rejects.toThrow(ValidationError);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should only send provided fields (partial update)', async () => {
|
|
97
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess({
|
|
98
|
+
user_id: 'user_123',
|
|
99
|
+
display_name: 'New Name',
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
await client.users.update('user_123', { displayName: 'New Name' });
|
|
103
|
+
|
|
104
|
+
const callBody = JSON.parse(global.fetch.mock.calls[0][1].body);
|
|
105
|
+
expect(callBody).toEqual({ display_name: 'New Name' });
|
|
106
|
+
expect(callBody.email).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('createBatch', () => {
|
|
111
|
+
it('should create multiple users', async () => {
|
|
112
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess({
|
|
113
|
+
results: [
|
|
114
|
+
{ index: 0, status: 'created', user_id: 'user_1' },
|
|
115
|
+
{ index: 1, status: 'created', user_id: 'user_2' },
|
|
116
|
+
],
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
const users = [
|
|
120
|
+
{ userId: 'user_1', displayName: 'User 1' },
|
|
121
|
+
{ userId: 'user_2', displayName: 'User 2' },
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const result = await client.users.createBatch(users);
|
|
125
|
+
|
|
126
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
127
|
+
expect.stringContaining('/users/batch'),
|
|
128
|
+
expect.objectContaining({ method: 'POST' })
|
|
129
|
+
);
|
|
130
|
+
expect(result.results).toHaveLength(2);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should throw ValidationError for non-array input', async () => {
|
|
134
|
+
await expect(client.users.createBatch('not-an-array')).rejects.toThrow(ValidationError);
|
|
135
|
+
await expect(client.users.createBatch('not-an-array')).rejects.toThrow('Users must be an array');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should throw ValidationError for empty array', async () => {
|
|
139
|
+
await expect(client.users.createBatch([])).rejects.toThrow(ValidationError);
|
|
140
|
+
await expect(client.users.createBatch([])).rejects.toThrow('Users array cannot be empty');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should throw ValidationError for batch exceeding 100 users', async () => {
|
|
144
|
+
const users = Array.from({ length: 101 }, (_, i) => ({ userId: `user_${i}` }));
|
|
145
|
+
await expect(client.users.createBatch(users)).rejects.toThrow(ValidationError);
|
|
146
|
+
await expect(client.users.createBatch(users)).rejects.toThrow('Batch size exceeds maximum of 100 users');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should throw ValidationError when a user is missing userId', async () => {
|
|
150
|
+
const users = [
|
|
151
|
+
{ userId: 'user_1' },
|
|
152
|
+
{ displayName: 'Missing ID' },
|
|
153
|
+
];
|
|
154
|
+
await expect(client.users.createBatch(users)).rejects.toThrow(ValidationError);
|
|
155
|
+
await expect(client.users.createBatch(users)).rejects.toThrow('User at index 1: User ID is required');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should throw ValidationError for invalid email in batch', async () => {
|
|
159
|
+
const users = [
|
|
160
|
+
{ userId: 'user_1', email: 'valid@example.com' },
|
|
161
|
+
{ userId: 'user_2', email: 'invalid-email' },
|
|
162
|
+
];
|
|
163
|
+
await expect(client.users.createBatch(users)).rejects.toThrow(ValidationError);
|
|
164
|
+
await expect(client.users.createBatch(users)).rejects.toThrow('User at index 1: Invalid email format');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
14
168
|
describe('get', () => {
|
|
15
169
|
it('should get a user profile', async () => {
|
|
16
|
-
global.fetch.mockResolvedValue(
|
|
170
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess(mockResponses.userProfile));
|
|
17
171
|
|
|
18
172
|
const result = await client.users.get('user_123');
|
|
19
173
|
|
|
20
174
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
21
|
-
expect.stringContaining('/
|
|
175
|
+
expect.stringContaining('/users/user_123'),
|
|
22
176
|
expect.any(Object)
|
|
23
177
|
);
|
|
24
|
-
expect(result).
|
|
178
|
+
expect(result.user_id).toBe('user123');
|
|
25
179
|
});
|
|
26
180
|
|
|
27
181
|
it('should throw 404 error when user not found', async () => {
|
|
@@ -31,20 +185,129 @@ describe('Users Resource', () => {
|
|
|
31
185
|
|
|
32
186
|
await expect(client.users.get('nonexistent_user')).rejects.toThrow("User 'user123' does not exist in this project");
|
|
33
187
|
});
|
|
188
|
+
|
|
189
|
+
it('should support field selection', async () => {
|
|
190
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess({
|
|
191
|
+
user_id: 'user_123',
|
|
192
|
+
points: 100,
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
await client.users.get('user_123', { fields: ['user_id', 'points'] });
|
|
196
|
+
|
|
197
|
+
const callUrl = global.fetch.mock.calls[0][0];
|
|
198
|
+
expect(callUrl).toContain('fields=user_id%2Cpoints');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should parse activity summary in profile', async () => {
|
|
202
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess({
|
|
203
|
+
user_id: 'user_123',
|
|
204
|
+
activity_summary: {
|
|
205
|
+
last_event_at: '2024-01-15T10:30:00Z',
|
|
206
|
+
event_count: 150,
|
|
207
|
+
days_active: 30,
|
|
208
|
+
},
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
const result = await client.users.get('user_123');
|
|
212
|
+
|
|
213
|
+
expect(result.activitySummary).toBeDefined();
|
|
214
|
+
expect(result.activitySummary.eventCount).toBe(150);
|
|
215
|
+
expect(result.activitySummary.daysActive).toBe(30);
|
|
216
|
+
expect(result.activitySummary.lastEventAt).toBeInstanceOf(Date);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should parse streak info in profile', async () => {
|
|
220
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess({
|
|
221
|
+
user_id: 'user_123',
|
|
222
|
+
streak: {
|
|
223
|
+
current_streak: 5,
|
|
224
|
+
longest_streak: 10,
|
|
225
|
+
last_activity_at: '2024-01-15T10:30:00Z',
|
|
226
|
+
},
|
|
227
|
+
}));
|
|
228
|
+
|
|
229
|
+
const result = await client.users.get('user_123');
|
|
230
|
+
|
|
231
|
+
expect(result.streak).toBeDefined();
|
|
232
|
+
expect(result.streak.currentStreak).toBe(5);
|
|
233
|
+
expect(result.streak.longestStreak).toBe(10);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should parse inventory summary in profile', async () => {
|
|
237
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess({
|
|
238
|
+
user_id: 'user_123',
|
|
239
|
+
inventory: {
|
|
240
|
+
item_count: 5,
|
|
241
|
+
active_effects: ['double_points', 'shield'],
|
|
242
|
+
},
|
|
243
|
+
}));
|
|
244
|
+
|
|
245
|
+
const result = await client.users.get('user_123');
|
|
246
|
+
|
|
247
|
+
expect(result.inventory).toBeDefined();
|
|
248
|
+
expect(result.inventory.itemCount).toBe(5);
|
|
249
|
+
expect(result.inventory.activeEffects).toEqual(['double_points', 'shield']);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('search', () => {
|
|
254
|
+
it('should search users with query', async () => {
|
|
255
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess({
|
|
256
|
+
users: [
|
|
257
|
+
{ user_id: 'user_1', display_name: 'John' },
|
|
258
|
+
{ user_id: 'user_2', display_name: 'Johnny' },
|
|
259
|
+
],
|
|
260
|
+
pagination: { page: 1, limit: 50, total: 2, totalPages: 1 },
|
|
261
|
+
}));
|
|
262
|
+
|
|
263
|
+
const result = await client.users.search('john');
|
|
264
|
+
|
|
265
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
266
|
+
expect.stringContaining('/users/search'),
|
|
267
|
+
expect.any(Object)
|
|
268
|
+
);
|
|
269
|
+
const callUrl = global.fetch.mock.calls[0][0];
|
|
270
|
+
expect(callUrl).toContain('q=john');
|
|
271
|
+
expect(result.users).toHaveLength(2);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should support pagination options', async () => {
|
|
275
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess({
|
|
276
|
+
users: [],
|
|
277
|
+
pagination: { page: 2, limit: 10, total: 0, totalPages: 0 },
|
|
278
|
+
}));
|
|
279
|
+
|
|
280
|
+
await client.users.search('test', { page: 2, limit: 10 });
|
|
281
|
+
|
|
282
|
+
const callUrl = global.fetch.mock.calls[0][0];
|
|
283
|
+
expect(callUrl).toContain('page=2');
|
|
284
|
+
expect(callUrl).toContain('limit=10');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should support field selection in search', async () => {
|
|
288
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess({
|
|
289
|
+
users: [],
|
|
290
|
+
pagination: { page: 1, limit: 50, total: 0, totalPages: 0 },
|
|
291
|
+
}));
|
|
292
|
+
|
|
293
|
+
await client.users.search('test', { fields: ['user_id', 'points'] });
|
|
294
|
+
|
|
295
|
+
const callUrl = global.fetch.mock.calls[0][0];
|
|
296
|
+
expect(callUrl).toContain('fields=user_id%2Cpoints');
|
|
297
|
+
});
|
|
34
298
|
});
|
|
35
299
|
|
|
36
300
|
describe('getBulk', () => {
|
|
37
301
|
it('should get multiple user profiles', async () => {
|
|
38
|
-
global.fetch.mockResolvedValue(
|
|
302
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess(mockResponses.bulkUsersResponse));
|
|
39
303
|
|
|
40
304
|
const result = await client.users.getBulk(['user1', 'user2']);
|
|
41
305
|
|
|
42
|
-
expect(result).toEqual(mockResponses.bulkUsersResponse);
|
|
43
306
|
expect(result.users).toHaveLength(2);
|
|
44
307
|
});
|
|
45
308
|
|
|
46
309
|
it('should handle empty results', async () => {
|
|
47
|
-
global.fetch.mockResolvedValue(
|
|
310
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess({ users: [] }));
|
|
48
311
|
|
|
49
312
|
const result = await client.users.getBulk(['nonexistent1']);
|
|
50
313
|
|
|
@@ -55,7 +318,7 @@ describe('Users Resource', () => {
|
|
|
55
318
|
describe('getBadges', () => {
|
|
56
319
|
it('should get user badges', async () => {
|
|
57
320
|
global.fetch.mockResolvedValue(
|
|
58
|
-
|
|
321
|
+
mockStandardizedSuccess({ badges: mockResponses.userProfile.badges })
|
|
59
322
|
);
|
|
60
323
|
|
|
61
324
|
const result = await client.users.getBadges('user_123');
|
|
@@ -66,7 +329,7 @@ describe('Users Resource', () => {
|
|
|
66
329
|
|
|
67
330
|
describe('getRank', () => {
|
|
68
331
|
it('should get user rank with default timeframe', async () => {
|
|
69
|
-
global.fetch.mockResolvedValue(
|
|
332
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess(mockResponses.userRankResponse));
|
|
70
333
|
|
|
71
334
|
const result = await client.users.getRank('user_123');
|
|
72
335
|
|
|
@@ -76,7 +339,7 @@ describe('Users Resource', () => {
|
|
|
76
339
|
});
|
|
77
340
|
|
|
78
341
|
it('should get user rank with weekly timeframe', async () => {
|
|
79
|
-
global.fetch.mockResolvedValue(
|
|
342
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess(mockResponses.userRankResponse));
|
|
80
343
|
|
|
81
344
|
await client.users.getRank('user_123', 'weekly');
|
|
82
345
|
|
|
@@ -87,7 +350,7 @@ describe('Users Resource', () => {
|
|
|
87
350
|
|
|
88
351
|
describe('submitAnswers', () => {
|
|
89
352
|
it('should submit questionnaire answers', async () => {
|
|
90
|
-
global.fetch.mockResolvedValue(
|
|
353
|
+
global.fetch.mockResolvedValue(mockStandardizedSuccess(mockResponses.answerSubmissionResponse));
|
|
91
354
|
|
|
92
355
|
const answers = [
|
|
93
356
|
{ question_id: 'q1', answer_option_id: 'a1' },
|
|
@@ -77,4 +77,91 @@ export const arbitraries = {
|
|
|
77
77
|
}),
|
|
78
78
|
{ minLength: 1, maxLength: 20 }
|
|
79
79
|
),
|
|
80
|
+
|
|
81
|
+
// Request ID
|
|
82
|
+
requestId: () => fc.string({ minLength: 10, maxLength: 50 }).map(s => `req_${s}`),
|
|
83
|
+
|
|
84
|
+
// HTTP status codes
|
|
85
|
+
httpStatusCode: () => fc.constantFrom(200, 201, 400, 401, 403, 404, 409, 429, 500, 502, 503),
|
|
86
|
+
|
|
87
|
+
// Error status codes only
|
|
88
|
+
errorStatusCode: () => fc.constantFrom(400, 401, 403, 404, 409, 429, 500, 502, 503),
|
|
89
|
+
|
|
90
|
+
// Rate limit info
|
|
91
|
+
rateLimitInfo: () => fc.record({
|
|
92
|
+
limit: fc.integer({ min: 100, max: 10000 }),
|
|
93
|
+
remaining: fc.integer({ min: 0, max: 10000 }),
|
|
94
|
+
reset: fc.integer({ min: 1700000000, max: 1800000000 }),
|
|
95
|
+
}).filter(r => r.remaining <= r.limit),
|
|
96
|
+
|
|
97
|
+
// Standardized success response
|
|
98
|
+
successResponse: () => fc.record({
|
|
99
|
+
success: fc.constant(true),
|
|
100
|
+
data: fc.dictionary(
|
|
101
|
+
fc.string({ minLength: 1, maxLength: 20 }),
|
|
102
|
+
fc.oneof(
|
|
103
|
+
fc.string(),
|
|
104
|
+
fc.integer(),
|
|
105
|
+
fc.boolean(),
|
|
106
|
+
fc.constant(null),
|
|
107
|
+
fc.array(fc.string(), { maxLength: 5 })
|
|
108
|
+
)
|
|
109
|
+
),
|
|
110
|
+
request_id: arbitraries.requestId(),
|
|
111
|
+
}),
|
|
112
|
+
|
|
113
|
+
// Standardized error response
|
|
114
|
+
errorResponse: () => fc.record({
|
|
115
|
+
success: fc.constant(false),
|
|
116
|
+
error: fc.record({
|
|
117
|
+
code: fc.constantFrom(
|
|
118
|
+
'VALIDATION_ERROR',
|
|
119
|
+
'INVALID_API_KEY',
|
|
120
|
+
'NOT_FOUND',
|
|
121
|
+
'CONFLICT',
|
|
122
|
+
'RATE_LIMIT_EXCEEDED',
|
|
123
|
+
'INTERNAL_ERROR'
|
|
124
|
+
),
|
|
125
|
+
message: fc.string({ minLength: 5, maxLength: 100 }),
|
|
126
|
+
details: fc.option(
|
|
127
|
+
fc.array(
|
|
128
|
+
fc.record({
|
|
129
|
+
field: fc.string({ minLength: 1, maxLength: 30 }),
|
|
130
|
+
message: fc.string({ minLength: 5, maxLength: 100 }),
|
|
131
|
+
}),
|
|
132
|
+
{ minLength: 1, maxLength: 5 }
|
|
133
|
+
),
|
|
134
|
+
{ nil: null }
|
|
135
|
+
),
|
|
136
|
+
}),
|
|
137
|
+
request_id: arbitraries.requestId(),
|
|
138
|
+
}),
|
|
139
|
+
|
|
140
|
+
// Pagination info
|
|
141
|
+
paginationInfo: () => fc.record({
|
|
142
|
+
page: fc.integer({ min: 1, max: 100 }),
|
|
143
|
+
limit: fc.integer({ min: 1, max: 100 }),
|
|
144
|
+
total: fc.integer({ min: 0, max: 10000 }),
|
|
145
|
+
totalPages: fc.integer({ min: 0, max: 100 }),
|
|
146
|
+
}),
|
|
147
|
+
|
|
148
|
+
// Valid API response (either success or error)
|
|
149
|
+
validApiResponse: () => fc.oneof(
|
|
150
|
+
arbitraries.successResponse(),
|
|
151
|
+
arbitraries.errorResponse()
|
|
152
|
+
),
|
|
153
|
+
|
|
154
|
+
// Error code to status mapping
|
|
155
|
+
errorCodeStatusPair: () => fc.constantFrom(
|
|
156
|
+
{ code: 'VALIDATION_ERROR', status: 400 },
|
|
157
|
+
{ code: 'INVALID_API_KEY', status: 401 },
|
|
158
|
+
{ code: 'FORBIDDEN', status: 403 },
|
|
159
|
+
{ code: 'NOT_FOUND', status: 404 },
|
|
160
|
+
{ code: 'USER_NOT_FOUND', status: 404 },
|
|
161
|
+
{ code: 'CONFLICT', status: 409 },
|
|
162
|
+
{ code: 'USER_EXISTS', status: 409 },
|
|
163
|
+
{ code: 'RATE_LIMIT_EXCEEDED', status: 429 },
|
|
164
|
+
{ code: 'INTERNAL_ERROR', status: 500 },
|
|
165
|
+
{ code: 'SERVER_ERROR', status: 500 }
|
|
166
|
+
),
|
|
80
167
|
};
|
|
@@ -7,30 +7,70 @@ export function createMockFetch() {
|
|
|
7
7
|
return jest.fn();
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export function mockSuccessResponse(data, status = 200) {
|
|
10
|
+
export function mockSuccessResponse(data, status = 200, headers = {}) {
|
|
11
|
+
const defaultHeaders = {
|
|
12
|
+
'X-RateLimit-Limit': '1000',
|
|
13
|
+
'X-RateLimit-Remaining': '950',
|
|
14
|
+
'X-RateLimit-Reset': '1704067200',
|
|
15
|
+
'X-Request-Id': 'req_test123',
|
|
16
|
+
...headers,
|
|
17
|
+
};
|
|
18
|
+
|
|
11
19
|
return Promise.resolve({
|
|
12
20
|
ok: true,
|
|
13
21
|
status,
|
|
22
|
+
headers: createMockHeaders(defaultHeaders),
|
|
14
23
|
json: () => Promise.resolve(data),
|
|
15
24
|
text: () => Promise.resolve(JSON.stringify(data)),
|
|
16
25
|
});
|
|
17
26
|
}
|
|
18
27
|
|
|
19
|
-
export function
|
|
28
|
+
export function mockStandardizedSuccess(data, requestId = 'req_test123', headers = {}) {
|
|
29
|
+
const responseBody = {
|
|
30
|
+
success: true,
|
|
31
|
+
data,
|
|
32
|
+
request_id: requestId,
|
|
33
|
+
};
|
|
34
|
+
return mockSuccessResponse(responseBody, 200, { 'X-Request-Id': requestId, ...headers });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function mockErrorResponse(status, message, details = null, headers = {}) {
|
|
20
38
|
const errorData = {
|
|
21
|
-
|
|
22
|
-
|
|
39
|
+
success: false,
|
|
40
|
+
error: {
|
|
41
|
+
code: getErrorCodeForStatus(status),
|
|
42
|
+
message,
|
|
43
|
+
...(details && { details }),
|
|
44
|
+
},
|
|
45
|
+
request_id: headers['X-Request-Id'] || 'req_error123',
|
|
23
46
|
};
|
|
24
|
-
|
|
47
|
+
|
|
48
|
+
const defaultHeaders = {
|
|
49
|
+
'X-RateLimit-Limit': '1000',
|
|
50
|
+
'X-RateLimit-Remaining': '950',
|
|
51
|
+
'X-RateLimit-Reset': '1704067200',
|
|
52
|
+
...headers,
|
|
53
|
+
};
|
|
54
|
+
|
|
25
55
|
return Promise.resolve({
|
|
26
56
|
ok: false,
|
|
27
57
|
status,
|
|
28
58
|
statusText: message,
|
|
59
|
+
headers: createMockHeaders(defaultHeaders),
|
|
29
60
|
json: () => Promise.resolve(errorData),
|
|
30
61
|
text: () => Promise.resolve(JSON.stringify(errorData)),
|
|
31
62
|
});
|
|
32
63
|
}
|
|
33
64
|
|
|
65
|
+
export function mockRateLimitResponse(retryAfter = 60) {
|
|
66
|
+
return mockErrorResponse(429, 'Rate limit exceeded', null, {
|
|
67
|
+
'Retry-After': String(retryAfter),
|
|
68
|
+
'X-RateLimit-Limit': '1000',
|
|
69
|
+
'X-RateLimit-Remaining': '0',
|
|
70
|
+
'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + retryAfter),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
34
74
|
export function mockNetworkError(message = 'Network error') {
|
|
35
75
|
return new Error(message);
|
|
36
76
|
}
|
|
@@ -40,3 +80,29 @@ export function mockTimeoutError() {
|
|
|
40
80
|
error.name = 'AbortError';
|
|
41
81
|
return error;
|
|
42
82
|
}
|
|
83
|
+
|
|
84
|
+
function getErrorCodeForStatus(status) {
|
|
85
|
+
const codeMap = {
|
|
86
|
+
400: 'VALIDATION_ERROR',
|
|
87
|
+
401: 'INVALID_API_KEY',
|
|
88
|
+
403: 'FORBIDDEN',
|
|
89
|
+
404: 'NOT_FOUND',
|
|
90
|
+
409: 'CONFLICT',
|
|
91
|
+
429: 'RATE_LIMIT_EXCEEDED',
|
|
92
|
+
500: 'INTERNAL_ERROR',
|
|
93
|
+
502: 'BAD_GATEWAY',
|
|
94
|
+
503: 'SERVICE_UNAVAILABLE',
|
|
95
|
+
};
|
|
96
|
+
return codeMap[status] || 'UNKNOWN_ERROR';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create mock headers object that mimics the Headers API
|
|
101
|
+
*/
|
|
102
|
+
export function createMockHeaders(headersObj = {}) {
|
|
103
|
+
return {
|
|
104
|
+
get: (name) => headersObj[name] || headersObj[name.toLowerCase()] || null,
|
|
105
|
+
has: (name) => name in headersObj || name.toLowerCase() in headersObj,
|
|
106
|
+
entries: () => Object.entries(headersObj),
|
|
107
|
+
};
|
|
108
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rooguys SDK Error Classes
|
|
3
|
+
* Typed error classes for different API error scenarios
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Base error class for all Rooguys SDK errors
|
|
8
|
+
*/
|
|
9
|
+
export class RooguysError extends Error {
|
|
10
|
+
constructor(message, { code = 'UNKNOWN_ERROR', requestId = null, statusCode = 500 } = {}) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'RooguysError';
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.requestId = requestId;
|
|
15
|
+
this.statusCode = statusCode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
toJSON() {
|
|
19
|
+
return {
|
|
20
|
+
name: this.name,
|
|
21
|
+
message: this.message,
|
|
22
|
+
code: this.code,
|
|
23
|
+
requestId: this.requestId,
|
|
24
|
+
statusCode: this.statusCode,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Validation error (HTTP 400)
|
|
31
|
+
* Thrown when request validation fails
|
|
32
|
+
*/
|
|
33
|
+
export class ValidationError extends RooguysError {
|
|
34
|
+
constructor(message, { code = 'VALIDATION_ERROR', requestId = null, fieldErrors = null } = {}) {
|
|
35
|
+
super(message, { code, requestId, statusCode: 400 });
|
|
36
|
+
this.name = 'ValidationError';
|
|
37
|
+
this.fieldErrors = fieldErrors;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
toJSON() {
|
|
41
|
+
return {
|
|
42
|
+
...super.toJSON(),
|
|
43
|
+
fieldErrors: this.fieldErrors,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Authentication error (HTTP 401)
|
|
50
|
+
* Thrown when API key is invalid or missing
|
|
51
|
+
*/
|
|
52
|
+
export class AuthenticationError extends RooguysError {
|
|
53
|
+
constructor(message, { code = 'AUTHENTICATION_ERROR', requestId = null } = {}) {
|
|
54
|
+
super(message, { code, requestId, statusCode: 401 });
|
|
55
|
+
this.name = 'AuthenticationError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Forbidden error (HTTP 403)
|
|
61
|
+
* Thrown when access is denied
|
|
62
|
+
*/
|
|
63
|
+
export class ForbiddenError extends RooguysError {
|
|
64
|
+
constructor(message, { code = 'FORBIDDEN', requestId = null } = {}) {
|
|
65
|
+
super(message, { code, requestId, statusCode: 403 });
|
|
66
|
+
this.name = 'ForbiddenError';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Not found error (HTTP 404)
|
|
72
|
+
* Thrown when requested resource doesn't exist
|
|
73
|
+
*/
|
|
74
|
+
export class NotFoundError extends RooguysError {
|
|
75
|
+
constructor(message, { code = 'NOT_FOUND', requestId = null } = {}) {
|
|
76
|
+
super(message, { code, requestId, statusCode: 404 });
|
|
77
|
+
this.name = 'NotFoundError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Conflict error (HTTP 409)
|
|
83
|
+
* Thrown when resource already exists or state conflict
|
|
84
|
+
*/
|
|
85
|
+
export class ConflictError extends RooguysError {
|
|
86
|
+
constructor(message, { code = 'CONFLICT', requestId = null } = {}) {
|
|
87
|
+
super(message, { code, requestId, statusCode: 409 });
|
|
88
|
+
this.name = 'ConflictError';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Rate limit error (HTTP 429)
|
|
94
|
+
* Thrown when rate limit is exceeded
|
|
95
|
+
*/
|
|
96
|
+
export class RateLimitError extends RooguysError {
|
|
97
|
+
constructor(message, { code = 'RATE_LIMIT_EXCEEDED', requestId = null, retryAfter = 60 } = {}) {
|
|
98
|
+
super(message, { code, requestId, statusCode: 429 });
|
|
99
|
+
this.name = 'RateLimitError';
|
|
100
|
+
this.retryAfter = retryAfter;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
toJSON() {
|
|
104
|
+
return {
|
|
105
|
+
...super.toJSON(),
|
|
106
|
+
retryAfter: this.retryAfter,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Server error (HTTP 500+)
|
|
113
|
+
* Thrown when server encounters an error
|
|
114
|
+
*/
|
|
115
|
+
export class ServerError extends RooguysError {
|
|
116
|
+
constructor(message, { code = 'SERVER_ERROR', requestId = null, statusCode = 500 } = {}) {
|
|
117
|
+
super(message, { code, requestId, statusCode });
|
|
118
|
+
this.name = 'ServerError';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Map HTTP status code to appropriate error class
|
|
124
|
+
* @param {number} status - HTTP status code
|
|
125
|
+
* @param {object} errorBody - Error response body
|
|
126
|
+
* @param {string} requestId - Request ID from response
|
|
127
|
+
* @param {object} headers - Response headers
|
|
128
|
+
* @returns {RooguysError} Appropriate error instance
|
|
129
|
+
*/
|
|
130
|
+
export function mapStatusToError(status, errorBody, requestId, headers = {}) {
|
|
131
|
+
const message = errorBody?.error?.message || errorBody?.error || errorBody?.message || 'An error occurred';
|
|
132
|
+
const code = errorBody?.error?.code || errorBody?.code || 'UNKNOWN_ERROR';
|
|
133
|
+
const fieldErrors = errorBody?.error?.details || errorBody?.details || null;
|
|
134
|
+
|
|
135
|
+
switch (status) {
|
|
136
|
+
case 400:
|
|
137
|
+
return new ValidationError(message, { code, requestId, fieldErrors });
|
|
138
|
+
case 401:
|
|
139
|
+
return new AuthenticationError(message, { code, requestId });
|
|
140
|
+
case 403:
|
|
141
|
+
return new ForbiddenError(message, { code, requestId });
|
|
142
|
+
case 404:
|
|
143
|
+
return new NotFoundError(message, { code, requestId });
|
|
144
|
+
case 409:
|
|
145
|
+
return new ConflictError(message, { code, requestId });
|
|
146
|
+
case 429: {
|
|
147
|
+
const retryAfter = parseInt(headers['retry-after'] || headers['Retry-After'] || '60', 10);
|
|
148
|
+
return new RateLimitError(message, { code, requestId, retryAfter });
|
|
149
|
+
}
|
|
150
|
+
default:
|
|
151
|
+
if (status >= 500) {
|
|
152
|
+
return new ServerError(message, { code, requestId, statusCode: status });
|
|
153
|
+
}
|
|
154
|
+
return new RooguysError(message, { code, requestId, statusCode: status });
|
|
155
|
+
}
|
|
156
|
+
}
|