@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,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
+ });