@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
@@ -2,6 +2,23 @@
2
2
  * Mock API response fixtures for testing
3
3
  */
4
4
 
5
+ // Standardized response format helpers
6
+ export const wrapSuccess = (data, requestId = 'req_test123') => ({
7
+ success: true,
8
+ data,
9
+ request_id: requestId,
10
+ });
11
+
12
+ export const wrapError = (code, message, details = null, requestId = 'req_test123') => ({
13
+ success: false,
14
+ error: {
15
+ code,
16
+ message,
17
+ ...(details && { details }),
18
+ },
19
+ request_id: requestId,
20
+ });
21
+
5
22
  export const mockResponses = {
6
23
  userProfile: {
7
24
  user_id: 'user123',
@@ -65,6 +82,21 @@ export const mockResponses = {
65
82
  },
66
83
  },
67
84
 
85
+ batchTrackEventResponse: {
86
+ results: [
87
+ { index: 0, status: 'queued' },
88
+ { index: 1, status: 'queued' },
89
+ ],
90
+ },
91
+
92
+ batchTrackEventWithErrorResponse: {
93
+ results: [
94
+ { index: 0, status: 'queued' },
95
+ { index: 1, status: 'error', error: 'Invalid event name' },
96
+ { index: 2, status: 'queued' },
97
+ ],
98
+ },
99
+
68
100
  leaderboardResponse: {
69
101
  timeframe: 'all-time',
70
102
  page: 1,
@@ -276,6 +308,95 @@ export const mockResponses = {
276
308
  totalPages: 1,
277
309
  },
278
310
  },
311
+
312
+ // Enhanced leaderboard responses with filters and cache metadata
313
+ leaderboardWithFiltersResponse: {
314
+ timeframe: 'all-time',
315
+ page: 1,
316
+ limit: 50,
317
+ total: 50,
318
+ rankings: [
319
+ {
320
+ rank: 1,
321
+ user_id: 'user1',
322
+ points: 1000,
323
+ percentile: 99.5,
324
+ level: {
325
+ id: 'level3',
326
+ name: 'Gold',
327
+ level_number: 3,
328
+ },
329
+ },
330
+ {
331
+ rank: 2,
332
+ user_id: 'user2',
333
+ points: 900,
334
+ percentile: 98.0,
335
+ level: {
336
+ id: 'level2',
337
+ name: 'Silver',
338
+ level_number: 2,
339
+ },
340
+ },
341
+ ],
342
+ cache_metadata: {
343
+ cached_at: '2024-01-15T10:30:00Z',
344
+ ttl: 300,
345
+ },
346
+ },
347
+
348
+ aroundUserResponse: {
349
+ user: {
350
+ user_id: 'user123',
351
+ rank: 42,
352
+ points: 850,
353
+ percentile: 95.8,
354
+ },
355
+ rankings: [
356
+ {
357
+ rank: 40,
358
+ user_id: 'user40',
359
+ points: 870,
360
+ percentile: 96.0,
361
+ },
362
+ {
363
+ rank: 41,
364
+ user_id: 'user41',
365
+ points: 860,
366
+ percentile: 95.9,
367
+ },
368
+ {
369
+ rank: 42,
370
+ user_id: 'user123',
371
+ points: 850,
372
+ percentile: 95.8,
373
+ },
374
+ {
375
+ rank: 43,
376
+ user_id: 'user43',
377
+ points: 840,
378
+ percentile: 95.7,
379
+ },
380
+ {
381
+ rank: 44,
382
+ user_id: 'user44',
383
+ points: 830,
384
+ percentile: 95.6,
385
+ },
386
+ ],
387
+ cache_metadata: {
388
+ cached_at: '2024-01-15T10:30:00Z',
389
+ ttl: 60,
390
+ },
391
+ },
392
+
393
+ userRankWithPercentileResponse: {
394
+ user_id: 'user123',
395
+ rank: 42,
396
+ points: 850,
397
+ total_users: 1000,
398
+ percentile: 95.8,
399
+ },
279
400
  };
280
401
 
281
402
  export const mockErrors = {
@@ -328,3 +449,131 @@ export const mockErrors = {
328
449
  ],
329
450
  },
330
451
  };
452
+
453
+
454
+ // Standardized format responses
455
+ export const standardizedResponses = {
456
+ userProfile: wrapSuccess({
457
+ user_id: 'user123',
458
+ points: 100,
459
+ persona: 'Achiever',
460
+ level: {
461
+ id: 'level1',
462
+ name: 'Bronze',
463
+ level_number: 1,
464
+ points_required: 0,
465
+ },
466
+ next_level: {
467
+ id: 'level2',
468
+ name: 'Silver',
469
+ level_number: 2,
470
+ points_required: 500,
471
+ points_needed: 400,
472
+ },
473
+ metrics: {
474
+ logins: 10,
475
+ purchases: 2,
476
+ },
477
+ badges: [],
478
+ activity_summary: {
479
+ last_event_at: '2024-01-15T10:30:00Z',
480
+ event_count: 150,
481
+ days_active: 30,
482
+ },
483
+ }),
484
+
485
+ trackEventResponse: wrapSuccess({
486
+ status: 'queued',
487
+ message: 'Event accepted for processing',
488
+ }),
489
+
490
+ batchTrackResponse: wrapSuccess({
491
+ results: [
492
+ { index: 0, status: 'queued' },
493
+ { index: 1, status: 'queued' },
494
+ { index: 2, status: 'error', error: 'Invalid event name' },
495
+ ],
496
+ }),
497
+
498
+ leaderboardResponse: wrapSuccess({
499
+ timeframe: 'all-time',
500
+ rankings: [
501
+ {
502
+ rank: 1,
503
+ user_id: 'user1',
504
+ score: 1000,
505
+ level_name: 'Gold',
506
+ level_number: 3,
507
+ percentile: 99.5,
508
+ },
509
+ {
510
+ rank: 2,
511
+ user_id: 'user2',
512
+ score: 900,
513
+ level_name: 'Silver',
514
+ level_number: 2,
515
+ percentile: 98.0,
516
+ },
517
+ ],
518
+ stats: {
519
+ total_users: 1000,
520
+ average_score: 250,
521
+ },
522
+ pagination: {
523
+ page: 1,
524
+ limit: 50,
525
+ total: 1000,
526
+ totalPages: 20,
527
+ },
528
+ }),
529
+
530
+ healthResponse: wrapSuccess({
531
+ status: 'healthy',
532
+ version: '1.0.0',
533
+ timestamp: '2024-01-15T10:30:00Z',
534
+ services: {
535
+ database: 'healthy',
536
+ redis: 'healthy',
537
+ queue: 'healthy',
538
+ },
539
+ queue_depth: 150,
540
+ processing_lag_ms: 50,
541
+ }),
542
+ };
543
+
544
+ export const standardizedErrors = {
545
+ validationError: wrapError('VALIDATION_ERROR', 'Validation failed', [
546
+ { field: 'user_id', message: 'User ID is required' },
547
+ { field: 'event_name', message: 'Event name is required' },
548
+ ]),
549
+
550
+ authenticationError: wrapError('INVALID_API_KEY', 'Invalid or missing API key'),
551
+
552
+ notFoundError: wrapError('USER_NOT_FOUND', "User 'user123' does not exist"),
553
+
554
+ conflictError: wrapError('USER_EXISTS', 'User with this ID already exists'),
555
+
556
+ rateLimitError: wrapError('RATE_LIMIT_EXCEEDED', 'Rate limit exceeded. Please retry later.'),
557
+
558
+ serverError: wrapError('INTERNAL_ERROR', 'An internal server error occurred'),
559
+ };
560
+
561
+ // Rate limit headers for testing
562
+ export const rateLimitHeaders = {
563
+ normal: {
564
+ 'X-RateLimit-Limit': '1000',
565
+ 'X-RateLimit-Remaining': '950',
566
+ 'X-RateLimit-Reset': '1704067200',
567
+ },
568
+ warning: {
569
+ 'X-RateLimit-Limit': '1000',
570
+ 'X-RateLimit-Remaining': '150',
571
+ 'X-RateLimit-Reset': '1704067200',
572
+ },
573
+ exceeded: {
574
+ 'X-RateLimit-Limit': '1000',
575
+ 'X-RateLimit-Remaining': '0',
576
+ 'X-RateLimit-Reset': '1704067200',
577
+ 'Retry-After': '60',
578
+ },
579
+ };
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Property-Based Test: Batch Event Validation
3
+ * Feature: sdk-documentation-update, Property 4: Batch Event Validation
4
+ * Validates: Requirements 3.1, 3.2
5
+ *
6
+ * For any array of events passed to trackBatch(), if the array length exceeds 100,
7
+ * the SDK SHALL throw a ValidationError before making an API request.
8
+ * If the array length is between 1 and 100, the SDK SHALL make exactly one API request.
9
+ */
10
+
11
+ import fc from 'fast-check';
12
+ import { jest } from '@jest/globals';
13
+ import Rooguys, { ValidationError } from '../../index.js';
14
+ import { createMockFetch, createMockHeaders } from '../utils/mockClient.js';
15
+
16
+ describe('Property 4: Batch Event Validation', () => {
17
+ let mockFetch;
18
+
19
+ beforeEach(() => {
20
+ mockFetch = createMockFetch();
21
+ global.fetch = mockFetch;
22
+ });
23
+
24
+ afterEach(() => {
25
+ jest.clearAllMocks();
26
+ });
27
+
28
+ const createBatchSuccessResponse = (count) => ({
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
+ results: Array.from({ length: count }, (_, i) => ({ index: i, status: 'queued' })),
39
+ },
40
+ }),
41
+ });
42
+
43
+ // Generator for valid batch events
44
+ const validBatchEvent = fc.record({
45
+ eventName: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
46
+ userId: fc.string({ minLength: 1, maxLength: 255 }).filter(s => s.trim().length > 0),
47
+ properties: fc.option(
48
+ fc.dictionary(
49
+ fc.string({ minLength: 1, maxLength: 50 }),
50
+ fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null))
51
+ ),
52
+ { nil: undefined }
53
+ ),
54
+ });
55
+
56
+ it('should throw ValidationError for arrays exceeding 100 events without making API request', async () => {
57
+ await fc.assert(
58
+ fc.asyncProperty(
59
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
60
+ fc.integer({ min: 101, max: 500 }), // Array size > 100
61
+ async (apiKey, arraySize) => {
62
+ // Arrange
63
+ mockFetch.mockClear();
64
+ const sdk = new Rooguys(apiKey);
65
+ const events = Array.from({ length: arraySize }, (_, i) => ({
66
+ eventName: `event_${i}`,
67
+ userId: `user_${i}`,
68
+ }));
69
+
70
+ // Act & Assert
71
+ await expect(sdk.events.trackBatch(events)).rejects.toThrow(ValidationError);
72
+ await expect(sdk.events.trackBatch(events)).rejects.toThrow('Batch size exceeds maximum of 100 events');
73
+
74
+ // Verify no API request was made
75
+ expect(mockFetch).not.toHaveBeenCalled();
76
+ }
77
+ ),
78
+ { numRuns: 100 }
79
+ );
80
+ });
81
+
82
+ it('should make exactly one API request for valid batch sizes (1-100)', async () => {
83
+ await fc.assert(
84
+ fc.asyncProperty(
85
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
86
+ fc.integer({ min: 1, max: 100 }), // Valid array size
87
+ async (apiKey, arraySize) => {
88
+ // Arrange
89
+ mockFetch.mockClear();
90
+ mockFetch.mockResolvedValue(createBatchSuccessResponse(arraySize));
91
+ const sdk = new Rooguys(apiKey);
92
+ const events = Array.from({ length: arraySize }, (_, i) => ({
93
+ eventName: `event_${i}`,
94
+ userId: `user_${i}`,
95
+ }));
96
+
97
+ // Act
98
+ const result = await sdk.events.trackBatch(events);
99
+
100
+ // Assert - exactly one API request
101
+ expect(mockFetch).toHaveBeenCalledTimes(1);
102
+
103
+ // Verify the request was to the batch endpoint
104
+ const callUrl = mockFetch.mock.calls[0][0];
105
+ expect(callUrl).toContain('/events/batch');
106
+
107
+ // Verify the request body contains all events
108
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
109
+ expect(callBody.events).toHaveLength(arraySize);
110
+
111
+ // Verify response contains results for all events
112
+ expect(result.results).toHaveLength(arraySize);
113
+ }
114
+ ),
115
+ { numRuns: 100 }
116
+ );
117
+ });
118
+
119
+ it('should throw ValidationError for empty arrays without making API request', async () => {
120
+ await fc.assert(
121
+ fc.asyncProperty(
122
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
123
+ async (apiKey) => {
124
+ // Arrange
125
+ mockFetch.mockClear();
126
+ const sdk = new Rooguys(apiKey);
127
+
128
+ // Act & Assert
129
+ await expect(sdk.events.trackBatch([])).rejects.toThrow(ValidationError);
130
+ await expect(sdk.events.trackBatch([])).rejects.toThrow('Events array cannot be empty');
131
+
132
+ // Verify no API request was made
133
+ expect(mockFetch).not.toHaveBeenCalled();
134
+ }
135
+ ),
136
+ { numRuns: 100 }
137
+ );
138
+ });
139
+
140
+ it('should throw ValidationError for non-array inputs without making API request', async () => {
141
+ await fc.assert(
142
+ fc.asyncProperty(
143
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
144
+ fc.oneof(
145
+ fc.string(),
146
+ fc.integer(),
147
+ fc.boolean(),
148
+ fc.constant(null),
149
+ fc.constant(undefined),
150
+ fc.record({ eventName: fc.string(), userId: fc.string() }) // Single object instead of array
151
+ ),
152
+ async (apiKey, invalidInput) => {
153
+ // Arrange
154
+ mockFetch.mockClear();
155
+ const sdk = new Rooguys(apiKey);
156
+
157
+ // Act & Assert
158
+ await expect(sdk.events.trackBatch(invalidInput)).rejects.toThrow(ValidationError);
159
+
160
+ // Verify no API request was made
161
+ expect(mockFetch).not.toHaveBeenCalled();
162
+ }
163
+ ),
164
+ { numRuns: 100 }
165
+ );
166
+ });
167
+
168
+ it('should correctly transform event properties in batch request', async () => {
169
+ await fc.assert(
170
+ fc.asyncProperty(
171
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
172
+ fc.array(validBatchEvent, { minLength: 1, maxLength: 50 }),
173
+ async (apiKey, events) => {
174
+ // Arrange
175
+ mockFetch.mockClear();
176
+ mockFetch.mockResolvedValue(createBatchSuccessResponse(events.length));
177
+ const sdk = new Rooguys(apiKey);
178
+
179
+ // Act
180
+ await sdk.events.trackBatch(events);
181
+
182
+ // Assert - verify transformation
183
+ const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
184
+
185
+ events.forEach((event, index) => {
186
+ expect(callBody.events[index].event_name).toBe(event.eventName);
187
+ expect(callBody.events[index].user_id).toBe(event.userId);
188
+ expect(callBody.events[index].properties).toEqual(event.properties || {});
189
+ });
190
+ }
191
+ ),
192
+ { numRuns: 100 }
193
+ );
194
+ });
195
+
196
+ it('should return individual status for each event in batch', async () => {
197
+ await fc.assert(
198
+ fc.asyncProperty(
199
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
200
+ fc.integer({ min: 1, max: 50 }), // Batch size
201
+ async (apiKey, batchSize) => {
202
+ // Arrange
203
+ mockFetch.mockClear();
204
+ mockFetch.mockResolvedValue(createBatchSuccessResponse(batchSize));
205
+ const sdk = new Rooguys(apiKey);
206
+ const events = Array.from({ length: batchSize }, (_, i) => ({
207
+ eventName: `event_${i}`,
208
+ userId: `user_${i}`,
209
+ }));
210
+
211
+ // Act
212
+ const result = await sdk.events.trackBatch(events);
213
+
214
+ // Assert - each event has a status
215
+ expect(result.results).toHaveLength(batchSize);
216
+ result.results.forEach((eventResult, index) => {
217
+ expect(eventResult.index).toBe(index);
218
+ expect(eventResult.status).toBeDefined();
219
+ });
220
+ }
221
+ ),
222
+ { numRuns: 100 }
223
+ );
224
+ });
225
+ });