@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
@@ -1,5 +1,6 @@
1
- import Rooguys from '../../index';
2
- import { mockSuccessResponse, mockErrorResponse, mockTimeoutError } from '../utils/mockClient';
1
+ import { jest } from '@jest/globals';
2
+ import Rooguys, { ValidationError } from '../../index';
3
+ import { mockStandardizedSuccess, mockErrorResponse, mockTimeoutError } from '../utils/mockClient';
3
4
  import { mockResponses, mockErrors } from '../fixtures/responses';
4
5
 
5
6
  describe('Events Resource', () => {
@@ -14,7 +15,7 @@ describe('Events Resource', () => {
14
15
  describe('track', () => {
15
16
  it('should track an event with valid inputs', async () => {
16
17
  global.fetch.mockResolvedValue(
17
- mockSuccessResponse(mockResponses.trackEventResponse)
18
+ mockStandardizedSuccess(mockResponses.trackEventResponse)
18
19
  );
19
20
 
20
21
  const result = await client.events.track('purchase_completed', 'user_123', {
@@ -22,7 +23,7 @@ describe('Events Resource', () => {
22
23
  });
23
24
 
24
25
  expect(global.fetch).toHaveBeenCalledWith(
25
- expect.stringContaining('/event'),
26
+ expect.stringContaining('/events'),
26
27
  expect.objectContaining({
27
28
  method: 'POST',
28
29
  headers: expect.objectContaining({
@@ -41,7 +42,7 @@ describe('Events Resource', () => {
41
42
 
42
43
  it('should track an event with empty properties', async () => {
43
44
  global.fetch.mockResolvedValue(
44
- mockSuccessResponse(mockResponses.trackEventResponse)
45
+ mockStandardizedSuccess(mockResponses.trackEventResponse)
45
46
  );
46
47
 
47
48
  const result = await client.events.track('user_login', 'user_456');
@@ -52,7 +53,7 @@ describe('Events Resource', () => {
52
53
 
53
54
  it('should include profile when includeProfile is true', async () => {
54
55
  global.fetch.mockResolvedValue(
55
- mockSuccessResponse(mockResponses.trackEventWithProfileResponse)
56
+ mockStandardizedSuccess(mockResponses.trackEventWithProfileResponse)
56
57
  );
57
58
 
58
59
  const result = await client.events.track(
@@ -70,7 +71,7 @@ describe('Events Resource', () => {
70
71
 
71
72
  it('should handle special characters in event name', async () => {
72
73
  global.fetch.mockResolvedValue(
73
- mockSuccessResponse(mockResponses.trackEventResponse)
74
+ mockStandardizedSuccess(mockResponses.trackEventResponse)
74
75
  );
75
76
 
76
77
  await client.events.track('user-signup_v2', 'user_123');
@@ -81,7 +82,7 @@ describe('Events Resource', () => {
81
82
 
82
83
  it('should handle special characters in user ID', async () => {
83
84
  global.fetch.mockResolvedValue(
84
- mockSuccessResponse(mockResponses.trackEventResponse)
85
+ mockStandardizedSuccess(mockResponses.trackEventResponse)
85
86
  );
86
87
 
87
88
  await client.events.track('user_login', 'user@example.com');
@@ -92,7 +93,7 @@ describe('Events Resource', () => {
92
93
 
93
94
  it('should handle complex nested properties', async () => {
94
95
  global.fetch.mockResolvedValue(
95
- mockSuccessResponse(mockResponses.trackEventResponse)
96
+ mockStandardizedSuccess(mockResponses.trackEventResponse)
96
97
  );
97
98
 
98
99
  const complexProperties = {
@@ -151,12 +152,12 @@ describe('Events Resource', () => {
151
152
 
152
153
  await expect(
153
154
  client.events.track('user_login', 'user_123')
154
- ).rejects.toThrow('aborted');
155
+ ).rejects.toThrow('Request timeout');
155
156
  });
156
157
 
157
158
  it('should handle properties with null values', async () => {
158
159
  global.fetch.mockResolvedValue(
159
- mockSuccessResponse(mockResponses.trackEventResponse)
160
+ mockStandardizedSuccess(mockResponses.trackEventResponse)
160
161
  );
161
162
 
162
163
  await client.events.track('user_updated', 'user_123', {
@@ -175,7 +176,7 @@ describe('Events Resource', () => {
175
176
 
176
177
  it('should handle properties with boolean values', async () => {
177
178
  global.fetch.mockResolvedValue(
178
- mockSuccessResponse(mockResponses.trackEventResponse)
179
+ mockStandardizedSuccess(mockResponses.trackEventResponse)
179
180
  );
180
181
 
181
182
  await client.events.track('feature_toggled', 'user_123', {
@@ -189,7 +190,7 @@ describe('Events Resource', () => {
189
190
 
190
191
  it('should handle properties with numeric values', async () => {
191
192
  global.fetch.mockResolvedValue(
192
- mockSuccessResponse(mockResponses.trackEventResponse)
193
+ mockStandardizedSuccess(mockResponses.trackEventResponse)
193
194
  );
194
195
 
195
196
  await client.events.track('score_updated', 'user_123', {
@@ -208,7 +209,7 @@ describe('Events Resource', () => {
208
209
 
209
210
  it('should handle empty string properties', async () => {
210
211
  global.fetch.mockResolvedValue(
211
- mockSuccessResponse(mockResponses.trackEventResponse)
212
+ mockStandardizedSuccess(mockResponses.trackEventResponse)
212
213
  );
213
214
 
214
215
  await client.events.track('form_submitted', 'user_123', {
@@ -219,5 +220,243 @@ describe('Events Resource', () => {
219
220
  const callBody = JSON.parse(global.fetch.mock.calls[0][1].body);
220
221
  expect(callBody.properties.comment).toBe('');
221
222
  });
223
+
224
+ it('should include idempotency key header when provided', async () => {
225
+ global.fetch.mockResolvedValue(
226
+ mockStandardizedSuccess(mockResponses.trackEventResponse)
227
+ );
228
+
229
+ await client.events.track('purchase_completed', 'user_123', { amount: 50 }, {
230
+ idempotencyKey: 'unique-key-123',
231
+ });
232
+
233
+ const callHeaders = global.fetch.mock.calls[0][1].headers;
234
+ expect(callHeaders['X-Idempotency-Key']).toBe('unique-key-123');
235
+ });
236
+
237
+ it('should include custom timestamp when provided', async () => {
238
+ global.fetch.mockResolvedValue(
239
+ mockStandardizedSuccess(mockResponses.trackEventResponse)
240
+ );
241
+
242
+ const timestamp = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
243
+ await client.events.track('historical_event', 'user_123', {}, {
244
+ timestamp,
245
+ });
246
+
247
+ const callBody = JSON.parse(global.fetch.mock.calls[0][1].body);
248
+ expect(callBody.timestamp).toBe(timestamp.toISOString());
249
+ });
250
+
251
+ it('should throw ValidationError when timestamp is more than 7 days old', async () => {
252
+ const oldTimestamp = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); // 8 days ago
253
+
254
+ await expect(
255
+ client.events.track('old_event', 'user_123', {}, { timestamp: oldTimestamp })
256
+ ).rejects.toThrow(ValidationError);
257
+
258
+ await expect(
259
+ client.events.track('old_event', 'user_123', {}, { timestamp: oldTimestamp })
260
+ ).rejects.toThrow('Custom timestamp cannot be more than 7 days in the past');
261
+ });
222
262
  });
263
+
264
+ describe('trackBatch', () => {
265
+ it('should track multiple events in a single request', async () => {
266
+ global.fetch.mockResolvedValue(
267
+ mockStandardizedSuccess(mockResponses.batchTrackEventResponse)
268
+ );
269
+
270
+ const events = [
271
+ { eventName: 'event_1', userId: 'user_1', properties: { key: 'value1' } },
272
+ { eventName: 'event_2', userId: 'user_2', properties: { key: 'value2' } },
273
+ ];
274
+
275
+ const result = await client.events.trackBatch(events);
276
+
277
+ expect(global.fetch).toHaveBeenCalledWith(
278
+ expect.stringContaining('/events/batch'),
279
+ expect.objectContaining({
280
+ method: 'POST',
281
+ body: JSON.stringify({
282
+ events: [
283
+ { event_name: 'event_1', user_id: 'user_1', properties: { key: 'value1' } },
284
+ { event_name: 'event_2', user_id: 'user_2', properties: { key: 'value2' } },
285
+ ],
286
+ }),
287
+ })
288
+ );
289
+ expect(result).toEqual(mockResponses.batchTrackEventResponse);
290
+ });
291
+
292
+ it('should include idempotency key header when provided', async () => {
293
+ global.fetch.mockResolvedValue(
294
+ mockStandardizedSuccess(mockResponses.batchTrackEventResponse)
295
+ );
296
+
297
+ const events = [
298
+ { eventName: 'event_1', userId: 'user_1' },
299
+ ];
300
+
301
+ await client.events.trackBatch(events, { idempotencyKey: 'batch-key-123' });
302
+
303
+ const callHeaders = global.fetch.mock.calls[0][1].headers;
304
+ expect(callHeaders['X-Idempotency-Key']).toBe('batch-key-123');
305
+ });
306
+
307
+ it('should throw ValidationError when events is not an array', async () => {
308
+ await expect(
309
+ client.events.trackBatch('not-an-array')
310
+ ).rejects.toThrow(ValidationError);
311
+
312
+ await expect(
313
+ client.events.trackBatch('not-an-array')
314
+ ).rejects.toThrow('Events must be an array');
315
+ });
316
+
317
+ it('should throw ValidationError when events array is empty', async () => {
318
+ await expect(
319
+ client.events.trackBatch([])
320
+ ).rejects.toThrow(ValidationError);
321
+
322
+ await expect(
323
+ client.events.trackBatch([])
324
+ ).rejects.toThrow('Events array cannot be empty');
325
+ });
326
+
327
+ it('should throw ValidationError when batch exceeds 100 events', async () => {
328
+ const events = Array.from({ length: 101 }, (_, i) => ({
329
+ eventName: `event_${i}`,
330
+ userId: `user_${i}`,
331
+ }));
332
+
333
+ await expect(
334
+ client.events.trackBatch(events)
335
+ ).rejects.toThrow(ValidationError);
336
+
337
+ await expect(
338
+ client.events.trackBatch(events)
339
+ ).rejects.toThrow('Batch size exceeds maximum of 100 events');
340
+ });
341
+
342
+ it('should accept exactly 100 events', async () => {
343
+ global.fetch.mockResolvedValue(
344
+ mockStandardizedSuccess({ results: Array.from({ length: 100 }, (_, i) => ({ index: i, status: 'queued' })) })
345
+ );
346
+
347
+ const events = Array.from({ length: 100 }, (_, i) => ({
348
+ eventName: `event_${i}`,
349
+ userId: `user_${i}`,
350
+ }));
351
+
352
+ await expect(client.events.trackBatch(events)).resolves.toBeDefined();
353
+ });
354
+
355
+ it('should include custom timestamps in batch events', async () => {
356
+ global.fetch.mockResolvedValue(
357
+ mockStandardizedSuccess(mockResponses.batchTrackEventResponse)
358
+ );
359
+
360
+ const timestamp = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
361
+ const events = [
362
+ { eventName: 'event_1', userId: 'user_1', timestamp },
363
+ { eventName: 'event_2', userId: 'user_2' },
364
+ ];
365
+
366
+ await client.events.trackBatch(events);
367
+
368
+ const callBody = JSON.parse(global.fetch.mock.calls[0][1].body);
369
+ expect(callBody.events[0].timestamp).toBe(timestamp.toISOString());
370
+ expect(callBody.events[1].timestamp).toBeUndefined();
371
+ });
372
+
373
+ it('should throw ValidationError when any batch event has timestamp older than 7 days', async () => {
374
+ const oldTimestamp = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); // 8 days ago
375
+ const events = [
376
+ { eventName: 'event_1', userId: 'user_1' },
377
+ { eventName: 'event_2', userId: 'user_2', timestamp: oldTimestamp },
378
+ ];
379
+
380
+ await expect(
381
+ client.events.trackBatch(events)
382
+ ).rejects.toThrow(ValidationError);
383
+
384
+ await expect(
385
+ client.events.trackBatch(events)
386
+ ).rejects.toThrow('Event at index 1: Custom timestamp cannot be more than 7 days in the past');
387
+ });
388
+
389
+ it('should handle batch response with mixed results', async () => {
390
+ global.fetch.mockResolvedValue(
391
+ mockStandardizedSuccess(mockResponses.batchTrackEventWithErrorResponse)
392
+ );
393
+
394
+ const events = [
395
+ { eventName: 'event_1', userId: 'user_1' },
396
+ { eventName: '', userId: 'user_2' },
397
+ { eventName: 'event_3', userId: 'user_3' },
398
+ ];
399
+
400
+ const result = await client.events.trackBatch(events);
401
+
402
+ expect(result.results[0].status).toBe('queued');
403
+ expect(result.results[1].status).toBe('error');
404
+ expect(result.results[1].error).toBe('Invalid event name');
405
+ expect(result.results[2].status).toBe('queued');
406
+ });
407
+
408
+ it('should handle events with empty properties', async () => {
409
+ global.fetch.mockResolvedValue(
410
+ mockStandardizedSuccess(mockResponses.batchTrackEventResponse)
411
+ );
412
+
413
+ const events = [
414
+ { eventName: 'event_1', userId: 'user_1' },
415
+ { eventName: 'event_2', userId: 'user_2', properties: {} },
416
+ ];
417
+
418
+ await client.events.trackBatch(events);
419
+
420
+ const callBody = JSON.parse(global.fetch.mock.calls[0][1].body);
421
+ expect(callBody.events[0].properties).toEqual({});
422
+ expect(callBody.events[1].properties).toEqual({});
423
+ });
424
+ });
425
+
426
+ describe('trackLegacy (deprecated)', () => {
427
+ it('should log deprecation warning when called', async () => {
428
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
429
+ global.fetch.mockResolvedValue(
430
+ mockStandardizedSuccess(mockResponses.trackEventResponse)
431
+ );
432
+
433
+ await client.events.trackLegacy('user_login', 'user_123');
434
+
435
+ expect(warnSpy).toHaveBeenCalledWith(
436
+ expect.stringContaining('DEPRECATION WARNING')
437
+ );
438
+ expect(warnSpy).toHaveBeenCalledWith(
439
+ expect.stringContaining('/v1/event')
440
+ );
441
+ warnSpy.mockRestore();
442
+ });
443
+
444
+ it('should use the legacy /event endpoint', async () => {
445
+ jest.spyOn(console, 'warn').mockImplementation(() => {});
446
+ global.fetch.mockResolvedValue(
447
+ mockStandardizedSuccess(mockResponses.trackEventResponse)
448
+ );
449
+
450
+ await client.events.trackLegacy('user_login', 'user_123');
451
+
452
+ expect(global.fetch).toHaveBeenCalledWith(
453
+ expect.stringContaining('/event'),
454
+ expect.any(Object)
455
+ );
456
+ // Should NOT contain /events (plural)
457
+ const callUrl = global.fetch.mock.calls[0][0];
458
+ expect(callUrl).not.toContain('/events');
459
+ });
460
+ });
461
+
223
462
  });
@@ -43,6 +43,93 @@ describe('Leaderboards Resource', () => {
43
43
  expect(callUrl).toContain('limit=25');
44
44
  });
45
45
 
46
+ it('should support options object signature', async () => {
47
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.leaderboardWithFiltersResponse));
48
+
49
+ await client.leaderboards.getGlobal({
50
+ timeframe: 'weekly',
51
+ page: 2,
52
+ limit: 25,
53
+ });
54
+
55
+ const callUrl = global.fetch.mock.calls[0][0];
56
+ expect(callUrl).toContain('timeframe=weekly');
57
+ expect(callUrl).toContain('page=2');
58
+ expect(callUrl).toContain('limit=25');
59
+ });
60
+
61
+ it('should filter by persona', async () => {
62
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.leaderboardWithFiltersResponse));
63
+
64
+ await client.leaderboards.getGlobal({
65
+ persona: 'Achiever',
66
+ });
67
+
68
+ const callUrl = global.fetch.mock.calls[0][0];
69
+ expect(callUrl).toContain('persona=Achiever');
70
+ });
71
+
72
+ it('should filter by level range', async () => {
73
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.leaderboardWithFiltersResponse));
74
+
75
+ await client.leaderboards.getGlobal({
76
+ minLevel: 2,
77
+ maxLevel: 5,
78
+ });
79
+
80
+ const callUrl = global.fetch.mock.calls[0][0];
81
+ expect(callUrl).toContain('min_level=2');
82
+ expect(callUrl).toContain('max_level=5');
83
+ });
84
+
85
+ it('should filter by date range with Date objects', async () => {
86
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.leaderboardWithFiltersResponse));
87
+
88
+ const startDate = new Date('2024-01-01T00:00:00Z');
89
+ const endDate = new Date('2024-01-31T23:59:59Z');
90
+
91
+ await client.leaderboards.getGlobal({
92
+ startDate,
93
+ endDate,
94
+ });
95
+
96
+ const callUrl = global.fetch.mock.calls[0][0];
97
+ expect(callUrl).toContain('start_date=2024-01-01T00%3A00%3A00.000Z');
98
+ expect(callUrl).toContain('end_date=2024-01-31T23%3A59%3A59.000Z');
99
+ });
100
+
101
+ it('should filter by date range with ISO strings', async () => {
102
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.leaderboardWithFiltersResponse));
103
+
104
+ await client.leaderboards.getGlobal({
105
+ startDate: '2024-01-01T00:00:00Z',
106
+ endDate: '2024-01-31T23:59:59Z',
107
+ });
108
+
109
+ const callUrl = global.fetch.mock.calls[0][0];
110
+ expect(callUrl).toContain('start_date=');
111
+ expect(callUrl).toContain('end_date=');
112
+ });
113
+
114
+ it('should parse cache metadata in response', async () => {
115
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.leaderboardWithFiltersResponse));
116
+
117
+ const result = await client.leaderboards.getGlobal();
118
+
119
+ expect(result.cacheMetadata).toBeDefined();
120
+ expect(result.cacheMetadata.cachedAt).toBeInstanceOf(Date);
121
+ expect(result.cacheMetadata.ttl).toBe(300);
122
+ });
123
+
124
+ it('should include percentile in rankings', async () => {
125
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.leaderboardWithFiltersResponse));
126
+
127
+ const result = await client.leaderboards.getGlobal();
128
+
129
+ expect(result.rankings[0].percentile).toBe(99.5);
130
+ expect(result.rankings[1].percentile).toBe(98.0);
131
+ });
132
+
46
133
  it('should throw error for invalid timeframe', async () => {
47
134
  global.fetch.mockResolvedValue(
48
135
  mockErrorResponse(400, mockErrors.invalidTimeframeError.message)
@@ -52,6 +139,20 @@ describe('Leaderboards Resource', () => {
52
139
  'Timeframe must be one of'
53
140
  );
54
141
  });
142
+
143
+ it('should support combined filters with legacy signature', async () => {
144
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.leaderboardWithFiltersResponse));
145
+
146
+ await client.leaderboards.getGlobal('weekly', 1, 50, {
147
+ persona: 'Explorer',
148
+ minLevel: 3,
149
+ });
150
+
151
+ const callUrl = global.fetch.mock.calls[0][0];
152
+ expect(callUrl).toContain('timeframe=weekly');
153
+ expect(callUrl).toContain('persona=Explorer');
154
+ expect(callUrl).toContain('min_level=3');
155
+ });
55
156
  });
56
157
 
57
158
  describe('list', () => {
@@ -72,6 +173,21 @@ describe('Leaderboards Resource', () => {
72
173
  const callUrl = global.fetch.mock.calls[0][0];
73
174
  expect(callUrl).toContain('search=top');
74
175
  });
176
+
177
+ it('should support options object signature', async () => {
178
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.leaderboardsListResponse));
179
+
180
+ await client.leaderboards.list({
181
+ page: 2,
182
+ limit: 25,
183
+ search: 'weekly',
184
+ });
185
+
186
+ const callUrl = global.fetch.mock.calls[0][0];
187
+ expect(callUrl).toContain('page=2');
188
+ expect(callUrl).toContain('limit=25');
189
+ expect(callUrl).toContain('search=weekly');
190
+ });
75
191
  });
76
192
 
77
193
  describe('getCustom', () => {
@@ -84,6 +200,48 @@ describe('Leaderboards Resource', () => {
84
200
  expect(result.rankings).toBeDefined();
85
201
  });
86
202
 
203
+ it('should support options object signature with filters', async () => {
204
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.leaderboardWithFiltersResponse));
205
+
206
+ await client.leaderboards.getCustom('lb1', {
207
+ page: 2,
208
+ limit: 25,
209
+ persona: 'Competitor',
210
+ minLevel: 1,
211
+ maxLevel: 10,
212
+ });
213
+
214
+ const callUrl = global.fetch.mock.calls[0][0];
215
+ expect(callUrl).toContain('/leaderboards/lb1');
216
+ expect(callUrl).toContain('page=2');
217
+ expect(callUrl).toContain('limit=25');
218
+ expect(callUrl).toContain('persona=Competitor');
219
+ expect(callUrl).toContain('min_level=1');
220
+ expect(callUrl).toContain('max_level=10');
221
+ });
222
+
223
+ it('should filter by date range', async () => {
224
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.leaderboardWithFiltersResponse));
225
+
226
+ await client.leaderboards.getCustom('lb1', {
227
+ startDate: new Date('2024-01-01'),
228
+ endDate: new Date('2024-01-31'),
229
+ });
230
+
231
+ const callUrl = global.fetch.mock.calls[0][0];
232
+ expect(callUrl).toContain('start_date=');
233
+ expect(callUrl).toContain('end_date=');
234
+ });
235
+
236
+ it('should parse cache metadata', async () => {
237
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.leaderboardWithFiltersResponse));
238
+
239
+ const result = await client.leaderboards.getCustom('lb1');
240
+
241
+ expect(result.cacheMetadata).toBeDefined();
242
+ expect(result.cacheMetadata.cachedAt).toBeInstanceOf(Date);
243
+ });
244
+
87
245
  it('should throw 404 for non-existent leaderboard', async () => {
88
246
  global.fetch.mockResolvedValue(mockErrorResponse(404, 'Leaderboard not found'));
89
247
 
@@ -91,6 +249,18 @@ describe('Leaderboards Resource', () => {
91
249
  'Leaderboard not found'
92
250
  );
93
251
  });
252
+
253
+ it('should support legacy signature with additional options', async () => {
254
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.leaderboardWithFiltersResponse));
255
+
256
+ await client.leaderboards.getCustom('lb1', 1, 50, 'search', {
257
+ persona: 'Achiever',
258
+ });
259
+
260
+ const callUrl = global.fetch.mock.calls[0][0];
261
+ expect(callUrl).toContain('search=search');
262
+ expect(callUrl).toContain('persona=Achiever');
263
+ });
94
264
  });
95
265
 
96
266
  describe('getUserRank', () => {
@@ -102,6 +272,23 @@ describe('Leaderboards Resource', () => {
102
272
  expect(result.rank).toBe(42);
103
273
  });
104
274
 
275
+ it('should include percentile in response', async () => {
276
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.userRankWithPercentileResponse));
277
+
278
+ const result = await client.leaderboards.getUserRank('lb1', 'user_123');
279
+
280
+ expect(result.rank).toBe(42);
281
+ expect(result.percentile).toBe(95.8);
282
+ });
283
+
284
+ it('should return null percentile when not available', async () => {
285
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.userRankResponse));
286
+
287
+ const result = await client.leaderboards.getUserRank('lb1', 'user_123');
288
+
289
+ expect(result.percentile).toBeNull();
290
+ });
291
+
105
292
  it('should throw 404 for non-existent user or leaderboard', async () => {
106
293
  global.fetch.mockResolvedValue(mockErrorResponse(404, 'Not found'));
107
294
 
@@ -110,4 +297,66 @@ describe('Leaderboards Resource', () => {
110
297
  ).rejects.toThrow('Not found');
111
298
  });
112
299
  });
300
+
301
+ describe('getAroundUser', () => {
302
+ it('should get leaderboard entries around user with default range', async () => {
303
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.aroundUserResponse));
304
+
305
+ const result = await client.leaderboards.getAroundUser('lb1', 'user123');
306
+
307
+ const callUrl = global.fetch.mock.calls[0][0];
308
+ expect(callUrl).toContain('/leaderboards/lb1/users/user123/around');
309
+ expect(callUrl).toContain('range=5');
310
+ expect(result.rankings).toHaveLength(5);
311
+ expect(result.user).toBeDefined();
312
+ expect(result.user.rank).toBe(42);
313
+ });
314
+
315
+ it('should get leaderboard entries around user with custom range', async () => {
316
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.aroundUserResponse));
317
+
318
+ await client.leaderboards.getAroundUser('lb1', 'user123', 10);
319
+
320
+ const callUrl = global.fetch.mock.calls[0][0];
321
+ expect(callUrl).toContain('range=10');
322
+ });
323
+
324
+ it('should include percentile for all entries', async () => {
325
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.aroundUserResponse));
326
+
327
+ const result = await client.leaderboards.getAroundUser('lb1', 'user123');
328
+
329
+ result.rankings.forEach(entry => {
330
+ expect(entry.percentile).toBeDefined();
331
+ expect(typeof entry.percentile).toBe('number');
332
+ });
333
+ });
334
+
335
+ it('should parse cache metadata', async () => {
336
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.aroundUserResponse));
337
+
338
+ const result = await client.leaderboards.getAroundUser('lb1', 'user123');
339
+
340
+ expect(result.cacheMetadata).toBeDefined();
341
+ expect(result.cacheMetadata.cachedAt).toBeInstanceOf(Date);
342
+ expect(result.cacheMetadata.ttl).toBe(60);
343
+ });
344
+
345
+ it('should URL encode user ID', async () => {
346
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.aroundUserResponse));
347
+
348
+ await client.leaderboards.getAroundUser('lb1', 'user@example.com');
349
+
350
+ const callUrl = global.fetch.mock.calls[0][0];
351
+ expect(callUrl).toContain('user%40example.com');
352
+ });
353
+
354
+ it('should throw 404 for non-existent user', async () => {
355
+ global.fetch.mockResolvedValue(mockErrorResponse(404, 'User not found'));
356
+
357
+ await expect(
358
+ client.leaderboards.getAroundUser('lb1', 'invalid_user')
359
+ ).rejects.toThrow('User not found');
360
+ });
361
+ });
113
362
  });
@@ -1,5 +1,5 @@
1
1
  import Rooguys from '../../index';
2
- import { mockSuccessResponse, mockErrorResponse } from '../utils/mockClient';
2
+ import { mockStandardizedSuccess, mockErrorResponse } from '../utils/mockClient';
3
3
  import { mockResponses } from '../fixtures/responses';
4
4
 
5
5
  describe('Questionnaires Resource', () => {
@@ -13,12 +13,12 @@ describe('Questionnaires Resource', () => {
13
13
 
14
14
  describe('get', () => {
15
15
  it('should get questionnaire by slug', async () => {
16
- global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.questionnaireResponse));
16
+ global.fetch.mockResolvedValue(mockStandardizedSuccess(mockResponses.questionnaireResponse));
17
17
 
18
18
  const result = await client.questionnaires.get('user-persona');
19
19
 
20
20
  expect(global.fetch).toHaveBeenCalledWith(
21
- expect.stringContaining('/questionnaire/user-persona'),
21
+ expect.stringContaining('/questionnaires/user-persona'),
22
22
  expect.any(Object)
23
23
  );
24
24
  expect(result.slug).toBe('user-persona');
@@ -33,7 +33,7 @@ describe('Questionnaires Resource', () => {
33
33
  });
34
34
 
35
35
  it('should handle questionnaire with multiple questions', async () => {
36
- global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.questionnaireResponse));
36
+ global.fetch.mockResolvedValue(mockStandardizedSuccess(mockResponses.questionnaireResponse));
37
37
 
38
38
  const result = await client.questionnaires.get('user-persona');
39
39
 
@@ -44,12 +44,12 @@ describe('Questionnaires Resource', () => {
44
44
 
45
45
  describe('getActive', () => {
46
46
  it('should get active questionnaire', async () => {
47
- global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.questionnaireResponse));
47
+ global.fetch.mockResolvedValue(mockStandardizedSuccess(mockResponses.questionnaireResponse));
48
48
 
49
49
  const result = await client.questionnaires.getActive();
50
50
 
51
51
  expect(global.fetch).toHaveBeenCalledWith(
52
- expect.stringContaining('/questionnaire/active'),
52
+ expect.stringContaining('/questionnaires/active'),
53
53
  expect.any(Object)
54
54
  );
55
55
  expect(result.is_active).toBe(true);