@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.
Files changed (28) hide show
  1. package/README.md +342 -141
  2. package/package.json +1 -1
  3. package/src/__tests__/fixtures/responses.js +249 -0
  4. package/src/__tests__/property/batch-event-validation.property.test.js +225 -0
  5. package/src/__tests__/property/email-validation.property.test.js +272 -0
  6. package/src/__tests__/property/error-mapping.property.test.js +506 -0
  7. package/src/__tests__/property/field-selection.property.test.js +297 -0
  8. package/src/__tests__/property/idempotency-key.property.test.js +350 -0
  9. package/src/__tests__/property/leaderboard-filter.property.test.js +585 -0
  10. package/src/__tests__/property/partial-update.property.test.js +251 -0
  11. package/src/__tests__/property/rate-limit-error.property.test.js +276 -0
  12. package/src/__tests__/property/rate-limit-extraction.property.test.js +193 -0
  13. package/src/__tests__/property/request-construction.property.test.js +20 -28
  14. package/src/__tests__/property/response-format.property.test.js +418 -0
  15. package/src/__tests__/property/response-parsing.property.test.js +16 -21
  16. package/src/__tests__/property/timestamp-validation.property.test.js +345 -0
  17. package/src/__tests__/unit/aha.test.js +57 -26
  18. package/src/__tests__/unit/config.test.js +7 -1
  19. package/src/__tests__/unit/errors.test.js +6 -8
  20. package/src/__tests__/unit/events.test.js +253 -14
  21. package/src/__tests__/unit/leaderboards.test.js +249 -0
  22. package/src/__tests__/unit/questionnaires.test.js +6 -6
  23. package/src/__tests__/unit/users.test.js +275 -12
  24. package/src/__tests__/utils/generators.js +87 -0
  25. package/src/__tests__/utils/mockClient.js +71 -5
  26. package/src/errors.js +156 -0
  27. package/src/http-client.js +276 -0
  28. package/src/index.js +856 -66
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Property-Based Test: Error Response Mapping
3
+ * Feature: sdk-documentation-update, Property 3: Error Response Mapping
4
+ * Validates: Requirements 2.2, 8.1, 8.2, 8.4
5
+ *
6
+ * Tests that any API error response with HTTP status code and error body
7
+ * is mapped to the correct typed error (ValidationError for 400, AuthenticationError for 401,
8
+ * NotFoundError for 404, ConflictError for 409, RateLimitError for 429, ServerError for 500)
9
+ * with the error code and request ID preserved.
10
+ */
11
+
12
+ import fc from 'fast-check';
13
+ import { jest } from '@jest/globals';
14
+ import Rooguys, {
15
+ RooguysError,
16
+ ValidationError,
17
+ AuthenticationError,
18
+ ForbiddenError,
19
+ NotFoundError,
20
+ ConflictError,
21
+ RateLimitError,
22
+ ServerError,
23
+ mapStatusToError,
24
+ } from '../../index.js';
25
+ import { createMockFetch, createMockHeaders, mockErrorResponse } from '../utils/mockClient.js';
26
+ import { arbitraries } from '../utils/generators.js';
27
+
28
+ describe('Property 3: Error Response Mapping', () => {
29
+ let mockFetch;
30
+
31
+ beforeEach(() => {
32
+ mockFetch = createMockFetch();
33
+ global.fetch = mockFetch;
34
+ });
35
+
36
+ afterEach(() => {
37
+ jest.clearAllMocks();
38
+ });
39
+
40
+ describe('mapStatusToError', () => {
41
+ it('should map HTTP 400 to ValidationError', () => {
42
+ fc.assert(
43
+ fc.property(
44
+ fc.string({ minLength: 5, maxLength: 100 }),
45
+ fc.constantFrom('VALIDATION_ERROR', 'INVALID_INPUT', 'MISSING_FIELD'),
46
+ arbitraries.requestId(),
47
+ fc.option(
48
+ fc.array(
49
+ fc.record({
50
+ field: fc.string({ minLength: 1, maxLength: 30 }),
51
+ message: fc.string({ minLength: 5, maxLength: 100 }),
52
+ }),
53
+ { minLength: 1, maxLength: 5 }
54
+ ),
55
+ { nil: null }
56
+ ),
57
+ (message, code, requestId, fieldErrors) => {
58
+ // Arrange
59
+ const errorBody = {
60
+ error: { code, message, details: fieldErrors },
61
+ };
62
+
63
+ // Act
64
+ const error = mapStatusToError(400, errorBody, requestId, {});
65
+
66
+ // Assert
67
+ expect(error).toBeInstanceOf(ValidationError);
68
+ expect(error.statusCode).toBe(400);
69
+ expect(error.message).toBe(message);
70
+ expect(error.code).toBe(code);
71
+ expect(error.requestId).toBe(requestId);
72
+ expect(error.fieldErrors).toEqual(fieldErrors);
73
+ }
74
+ ),
75
+ { numRuns: 100 }
76
+ );
77
+ });
78
+
79
+ it('should map HTTP 401 to AuthenticationError', () => {
80
+ fc.assert(
81
+ fc.property(
82
+ fc.string({ minLength: 5, maxLength: 100 }),
83
+ fc.constantFrom('INVALID_API_KEY', 'MISSING_API_KEY', 'EXPIRED_TOKEN'),
84
+ arbitraries.requestId(),
85
+ (message, code, requestId) => {
86
+ // Arrange
87
+ const errorBody = { error: { code, message } };
88
+
89
+ // Act
90
+ const error = mapStatusToError(401, errorBody, requestId, {});
91
+
92
+ // Assert
93
+ expect(error).toBeInstanceOf(AuthenticationError);
94
+ expect(error.statusCode).toBe(401);
95
+ expect(error.message).toBe(message);
96
+ expect(error.code).toBe(code);
97
+ expect(error.requestId).toBe(requestId);
98
+ }
99
+ ),
100
+ { numRuns: 100 }
101
+ );
102
+ });
103
+
104
+ it('should map HTTP 403 to ForbiddenError', () => {
105
+ fc.assert(
106
+ fc.property(
107
+ fc.string({ minLength: 5, maxLength: 100 }),
108
+ fc.constantFrom('FORBIDDEN', 'ACCESS_DENIED', 'INSUFFICIENT_PERMISSIONS'),
109
+ arbitraries.requestId(),
110
+ (message, code, requestId) => {
111
+ // Arrange
112
+ const errorBody = { error: { code, message } };
113
+
114
+ // Act
115
+ const error = mapStatusToError(403, errorBody, requestId, {});
116
+
117
+ // Assert
118
+ expect(error).toBeInstanceOf(ForbiddenError);
119
+ expect(error.statusCode).toBe(403);
120
+ expect(error.message).toBe(message);
121
+ expect(error.code).toBe(code);
122
+ expect(error.requestId).toBe(requestId);
123
+ }
124
+ ),
125
+ { numRuns: 100 }
126
+ );
127
+ });
128
+
129
+ it('should map HTTP 404 to NotFoundError', () => {
130
+ fc.assert(
131
+ fc.property(
132
+ fc.string({ minLength: 5, maxLength: 100 }),
133
+ fc.constantFrom('NOT_FOUND', 'USER_NOT_FOUND', 'RESOURCE_NOT_FOUND'),
134
+ arbitraries.requestId(),
135
+ (message, code, requestId) => {
136
+ // Arrange
137
+ const errorBody = { error: { code, message } };
138
+
139
+ // Act
140
+ const error = mapStatusToError(404, errorBody, requestId, {});
141
+
142
+ // Assert
143
+ expect(error).toBeInstanceOf(NotFoundError);
144
+ expect(error.statusCode).toBe(404);
145
+ expect(error.message).toBe(message);
146
+ expect(error.code).toBe(code);
147
+ expect(error.requestId).toBe(requestId);
148
+ }
149
+ ),
150
+ { numRuns: 100 }
151
+ );
152
+ });
153
+
154
+ it('should map HTTP 409 to ConflictError', () => {
155
+ fc.assert(
156
+ fc.property(
157
+ fc.string({ minLength: 5, maxLength: 100 }),
158
+ fc.constantFrom('CONFLICT', 'USER_EXISTS', 'DUPLICATE_ENTRY'),
159
+ arbitraries.requestId(),
160
+ (message, code, requestId) => {
161
+ // Arrange
162
+ const errorBody = { error: { code, message } };
163
+
164
+ // Act
165
+ const error = mapStatusToError(409, errorBody, requestId, {});
166
+
167
+ // Assert
168
+ expect(error).toBeInstanceOf(ConflictError);
169
+ expect(error.statusCode).toBe(409);
170
+ expect(error.message).toBe(message);
171
+ expect(error.code).toBe(code);
172
+ expect(error.requestId).toBe(requestId);
173
+ }
174
+ ),
175
+ { numRuns: 100 }
176
+ );
177
+ });
178
+
179
+ it('should map HTTP 429 to RateLimitError with retryAfter', () => {
180
+ fc.assert(
181
+ fc.property(
182
+ fc.string({ minLength: 5, maxLength: 100 }),
183
+ fc.constantFrom('RATE_LIMIT_EXCEEDED', 'TOO_MANY_REQUESTS'),
184
+ arbitraries.requestId(),
185
+ fc.integer({ min: 1, max: 3600 }),
186
+ (message, code, requestId, retryAfter) => {
187
+ // Arrange
188
+ const errorBody = { error: { code, message } };
189
+ const headers = { 'retry-after': String(retryAfter) };
190
+
191
+ // Act
192
+ const error = mapStatusToError(429, errorBody, requestId, headers);
193
+
194
+ // Assert
195
+ expect(error).toBeInstanceOf(RateLimitError);
196
+ expect(error.statusCode).toBe(429);
197
+ expect(error.message).toBe(message);
198
+ expect(error.code).toBe(code);
199
+ expect(error.requestId).toBe(requestId);
200
+ expect(error.retryAfter).toBe(retryAfter);
201
+ }
202
+ ),
203
+ { numRuns: 100 }
204
+ );
205
+ });
206
+
207
+ it('should map HTTP 500+ to ServerError', () => {
208
+ fc.assert(
209
+ fc.property(
210
+ fc.constantFrom(500, 502, 503, 504),
211
+ fc.string({ minLength: 5, maxLength: 100 }),
212
+ fc.constantFrom('INTERNAL_ERROR', 'SERVER_ERROR', 'SERVICE_UNAVAILABLE'),
213
+ arbitraries.requestId(),
214
+ (status, message, code, requestId) => {
215
+ // Arrange
216
+ const errorBody = { error: { code, message } };
217
+
218
+ // Act
219
+ const error = mapStatusToError(status, errorBody, requestId, {});
220
+
221
+ // Assert
222
+ expect(error).toBeInstanceOf(ServerError);
223
+ expect(error.statusCode).toBe(status);
224
+ expect(error.message).toBe(message);
225
+ expect(error.code).toBe(code);
226
+ expect(error.requestId).toBe(requestId);
227
+ }
228
+ ),
229
+ { numRuns: 100 }
230
+ );
231
+ });
232
+
233
+ it('should preserve requestId in all error types', () => {
234
+ fc.assert(
235
+ fc.property(
236
+ arbitraries.errorCodeStatusPair(),
237
+ fc.string({ minLength: 5, maxLength: 100 }),
238
+ arbitraries.requestId(),
239
+ ({ code, status }, message, requestId) => {
240
+ // Arrange
241
+ const errorBody = { error: { code, message } };
242
+ const headers = status === 429 ? { 'retry-after': '60' } : {};
243
+
244
+ // Act
245
+ const error = mapStatusToError(status, errorBody, requestId, headers);
246
+
247
+ // Assert
248
+ expect(error.requestId).toBe(requestId);
249
+ expect(error.code).toBe(code);
250
+ }
251
+ ),
252
+ { numRuns: 100 }
253
+ );
254
+ });
255
+
256
+ it('should handle legacy error format (error as string)', () => {
257
+ fc.assert(
258
+ fc.property(
259
+ fc.constantFrom(400, 401, 404, 500),
260
+ fc.string({ minLength: 5, maxLength: 100 }),
261
+ arbitraries.requestId(),
262
+ (status, message, requestId) => {
263
+ // Arrange - legacy format with error as string
264
+ const errorBody = { error: message };
265
+
266
+ // Act
267
+ const error = mapStatusToError(status, errorBody, requestId, {});
268
+
269
+ // Assert
270
+ expect(error).toBeInstanceOf(RooguysError);
271
+ expect(error.message).toBe(message);
272
+ expect(error.requestId).toBe(requestId);
273
+ }
274
+ ),
275
+ { numRuns: 100 }
276
+ );
277
+ });
278
+ });
279
+
280
+ describe('SDK error handling integration', () => {
281
+ it('should throw correct error type for API errors', async () => {
282
+ await fc.assert(
283
+ fc.asyncProperty(
284
+ arbitraries.apiKey(),
285
+ arbitraries.userId(),
286
+ fc.constantFrom(
287
+ { status: 400, ErrorClass: ValidationError },
288
+ { status: 401, ErrorClass: AuthenticationError },
289
+ { status: 403, ErrorClass: ForbiddenError },
290
+ { status: 404, ErrorClass: NotFoundError },
291
+ { status: 409, ErrorClass: ConflictError },
292
+ { status: 429, ErrorClass: RateLimitError },
293
+ { status: 500, ErrorClass: ServerError }
294
+ ),
295
+ fc.string({ minLength: 5, maxLength: 100 }),
296
+ arbitraries.requestId(),
297
+ async (apiKey, userId, { status, ErrorClass }, message, requestId) => {
298
+ // Arrange
299
+ mockFetch.mockClear();
300
+ const errorResponse = {
301
+ success: false,
302
+ error: {
303
+ code: `ERROR_${status}`,
304
+ message,
305
+ },
306
+ request_id: requestId,
307
+ };
308
+ mockFetch.mockResolvedValue({
309
+ ok: false,
310
+ status,
311
+ statusText: message,
312
+ headers: createMockHeaders({
313
+ 'X-Request-Id': requestId,
314
+ 'Retry-After': '60',
315
+ }),
316
+ json: async () => errorResponse,
317
+ });
318
+ const sdk = new Rooguys(apiKey);
319
+
320
+ // Act & Assert
321
+ try {
322
+ await sdk.users.get(userId);
323
+ // Should not reach here
324
+ expect(true).toBe(false);
325
+ } catch (error) {
326
+ expect(error).toBeInstanceOf(ErrorClass);
327
+ expect(error.requestId).toBe(requestId);
328
+ expect(error.statusCode).toBe(status);
329
+ }
330
+ }
331
+ ),
332
+ { numRuns: 100 }
333
+ );
334
+ });
335
+
336
+ it('should include field errors in ValidationError', async () => {
337
+ await fc.assert(
338
+ fc.asyncProperty(
339
+ arbitraries.apiKey(),
340
+ arbitraries.userId(),
341
+ fc.array(
342
+ fc.record({
343
+ field: fc.string({ minLength: 1, maxLength: 30 }),
344
+ message: fc.string({ minLength: 5, maxLength: 100 }),
345
+ }),
346
+ { minLength: 1, maxLength: 5 }
347
+ ),
348
+ arbitraries.requestId(),
349
+ async (apiKey, userId, fieldErrors, requestId) => {
350
+ // Arrange
351
+ mockFetch.mockClear();
352
+ const errorResponse = {
353
+ success: false,
354
+ error: {
355
+ code: 'VALIDATION_ERROR',
356
+ message: 'Validation failed',
357
+ details: fieldErrors,
358
+ },
359
+ request_id: requestId,
360
+ };
361
+ mockFetch.mockResolvedValue({
362
+ ok: false,
363
+ status: 400,
364
+ statusText: 'Bad Request',
365
+ headers: createMockHeaders({ 'X-Request-Id': requestId }),
366
+ json: async () => errorResponse,
367
+ });
368
+ const sdk = new Rooguys(apiKey);
369
+
370
+ // Act & Assert
371
+ try {
372
+ await sdk.users.get(userId);
373
+ expect(true).toBe(false);
374
+ } catch (error) {
375
+ expect(error).toBeInstanceOf(ValidationError);
376
+ expect(error.fieldErrors).toEqual(fieldErrors);
377
+ }
378
+ }
379
+ ),
380
+ { numRuns: 100 }
381
+ );
382
+ });
383
+
384
+ it('should include retryAfter in RateLimitError', async () => {
385
+ await fc.assert(
386
+ fc.asyncProperty(
387
+ arbitraries.apiKey(),
388
+ arbitraries.userId(),
389
+ fc.integer({ min: 1, max: 3600 }),
390
+ arbitraries.requestId(),
391
+ async (apiKey, userId, retryAfter, requestId) => {
392
+ // Arrange
393
+ mockFetch.mockClear();
394
+ const errorResponse = {
395
+ success: false,
396
+ error: {
397
+ code: 'RATE_LIMIT_EXCEEDED',
398
+ message: 'Rate limit exceeded',
399
+ },
400
+ request_id: requestId,
401
+ };
402
+ mockFetch.mockResolvedValue({
403
+ ok: false,
404
+ status: 429,
405
+ statusText: 'Too Many Requests',
406
+ headers: createMockHeaders({
407
+ 'X-Request-Id': requestId,
408
+ 'Retry-After': String(retryAfter),
409
+ }),
410
+ json: async () => errorResponse,
411
+ });
412
+ const sdk = new Rooguys(apiKey);
413
+
414
+ // Act & Assert
415
+ try {
416
+ await sdk.users.get(userId);
417
+ expect(true).toBe(false);
418
+ } catch (error) {
419
+ expect(error).toBeInstanceOf(RateLimitError);
420
+ expect(error.retryAfter).toBe(retryAfter);
421
+ }
422
+ }
423
+ ),
424
+ { numRuns: 100 }
425
+ );
426
+ });
427
+ });
428
+
429
+ describe('Error serialization', () => {
430
+ it('should serialize errors to JSON with all properties', () => {
431
+ fc.assert(
432
+ fc.property(
433
+ fc.string({ minLength: 5, maxLength: 100 }),
434
+ fc.constantFrom('VALIDATION_ERROR', 'NOT_FOUND', 'SERVER_ERROR'),
435
+ arbitraries.requestId(),
436
+ fc.constantFrom(400, 404, 500),
437
+ (message, code, requestId, statusCode) => {
438
+ // Arrange
439
+ const error = new RooguysError(message, { code, requestId, statusCode });
440
+
441
+ // Act
442
+ const json = error.toJSON();
443
+
444
+ // Assert
445
+ expect(json.name).toBe('RooguysError');
446
+ expect(json.message).toBe(message);
447
+ expect(json.code).toBe(code);
448
+ expect(json.requestId).toBe(requestId);
449
+ expect(json.statusCode).toBe(statusCode);
450
+ }
451
+ ),
452
+ { numRuns: 100 }
453
+ );
454
+ });
455
+
456
+ it('should serialize ValidationError with fieldErrors', () => {
457
+ fc.assert(
458
+ fc.property(
459
+ fc.string({ minLength: 5, maxLength: 100 }),
460
+ arbitraries.requestId(),
461
+ fc.array(
462
+ fc.record({
463
+ field: fc.string({ minLength: 1, maxLength: 30 }),
464
+ message: fc.string({ minLength: 5, maxLength: 100 }),
465
+ }),
466
+ { minLength: 1, maxLength: 5 }
467
+ ),
468
+ (message, requestId, fieldErrors) => {
469
+ // Arrange
470
+ const error = new ValidationError(message, { requestId, fieldErrors });
471
+
472
+ // Act
473
+ const json = error.toJSON();
474
+
475
+ // Assert
476
+ expect(json.name).toBe('ValidationError');
477
+ expect(json.fieldErrors).toEqual(fieldErrors);
478
+ }
479
+ ),
480
+ { numRuns: 100 }
481
+ );
482
+ });
483
+
484
+ it('should serialize RateLimitError with retryAfter', () => {
485
+ fc.assert(
486
+ fc.property(
487
+ fc.string({ minLength: 5, maxLength: 100 }),
488
+ arbitraries.requestId(),
489
+ fc.integer({ min: 1, max: 3600 }),
490
+ (message, requestId, retryAfter) => {
491
+ // Arrange
492
+ const error = new RateLimitError(message, { requestId, retryAfter });
493
+
494
+ // Act
495
+ const json = error.toJSON();
496
+
497
+ // Assert
498
+ expect(json.name).toBe('RateLimitError');
499
+ expect(json.retryAfter).toBe(retryAfter);
500
+ }
501
+ ),
502
+ { numRuns: 100 }
503
+ );
504
+ });
505
+ });
506
+ });