@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,297 @@
1
+ /**
2
+ * Property-Based Test: Field Selection Query Construction
3
+ * Feature: sdk-documentation-update, Property 9: Field Selection Query Construction
4
+ * Validates: Requirements 5.1
5
+ *
6
+ * For any user profile request with a `fields` parameter, the SDK SHALL include
7
+ * a `fields` query parameter with the comma-separated list of requested fields.
8
+ */
9
+
10
+ import fc from 'fast-check';
11
+ import { jest } from '@jest/globals';
12
+ import Rooguys from '../../index.js';
13
+ import { createMockFetch, createMockHeaders } from '../utils/mockClient.js';
14
+
15
+ describe('Property 9: Field Selection Query Construction', () => {
16
+ let mockFetch;
17
+
18
+ beforeEach(() => {
19
+ mockFetch = createMockFetch();
20
+ global.fetch = mockFetch;
21
+ });
22
+
23
+ afterEach(() => {
24
+ jest.clearAllMocks();
25
+ });
26
+
27
+ const createSuccessResponse = (data) => ({
28
+ ok: true,
29
+ headers: createMockHeaders({
30
+ 'X-RateLimit-Limit': '1000',
31
+ 'X-RateLimit-Remaining': '950',
32
+ 'X-RateLimit-Reset': '1704067200',
33
+ }),
34
+ json: async () => ({
35
+ success: true,
36
+ data,
37
+ }),
38
+ });
39
+
40
+ // Generator for valid field names
41
+ const validFieldName = fc.constantFrom(
42
+ 'user_id',
43
+ 'display_name',
44
+ 'email',
45
+ 'points',
46
+ 'level',
47
+ 'badges',
48
+ 'metrics',
49
+ 'persona',
50
+ 'activity_summary',
51
+ 'streak',
52
+ 'inventory',
53
+ 'created_at',
54
+ 'updated_at'
55
+ );
56
+
57
+ // Generator for array of unique field names
58
+ const fieldArray = fc.uniqueArray(validFieldName, { minLength: 1, maxLength: 10 });
59
+
60
+ describe('users.get()', () => {
61
+ it('should include fields query parameter with comma-separated values', async () => {
62
+ await fc.assert(
63
+ fc.asyncProperty(
64
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
65
+ fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // User ID
66
+ fieldArray,
67
+ async (apiKey, userId, fields) => {
68
+ // Arrange
69
+ mockFetch.mockClear();
70
+ mockFetch.mockResolvedValue(createSuccessResponse({
71
+ user_id: userId,
72
+ }));
73
+ const sdk = new Rooguys(apiKey);
74
+
75
+ // Act
76
+ await sdk.users.get(userId, { fields });
77
+
78
+ // Assert
79
+ expect(mockFetch).toHaveBeenCalledTimes(1);
80
+ const callUrl = mockFetch.mock.calls[0][0];
81
+ const url = new URL(callUrl);
82
+
83
+ // Verify fields parameter exists
84
+ expect(url.searchParams.has('fields')).toBe(true);
85
+
86
+ // Verify comma-separated format
87
+ const fieldsParam = url.searchParams.get('fields');
88
+ const parsedFields = fieldsParam.split(',');
89
+
90
+ // All requested fields should be in the query
91
+ expect(parsedFields.sort()).toEqual(fields.sort());
92
+ }
93
+ ),
94
+ { numRuns: 100 }
95
+ );
96
+ });
97
+
98
+ it('should not include fields parameter when fields array is empty', async () => {
99
+ await fc.assert(
100
+ fc.asyncProperty(
101
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
102
+ fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // User ID
103
+ async (apiKey, userId) => {
104
+ // Arrange
105
+ mockFetch.mockClear();
106
+ mockFetch.mockResolvedValue(createSuccessResponse({
107
+ user_id: userId,
108
+ }));
109
+ const sdk = new Rooguys(apiKey);
110
+
111
+ // Act
112
+ await sdk.users.get(userId, { fields: [] });
113
+
114
+ // Assert
115
+ expect(mockFetch).toHaveBeenCalledTimes(1);
116
+ const callUrl = mockFetch.mock.calls[0][0];
117
+ const url = new URL(callUrl);
118
+
119
+ // Verify fields parameter is NOT present
120
+ expect(url.searchParams.has('fields')).toBe(false);
121
+ }
122
+ ),
123
+ { numRuns: 100 }
124
+ );
125
+ });
126
+
127
+ it('should not include fields parameter when options.fields is undefined', async () => {
128
+ await fc.assert(
129
+ fc.asyncProperty(
130
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
131
+ fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // User ID
132
+ async (apiKey, userId) => {
133
+ // Arrange
134
+ mockFetch.mockClear();
135
+ mockFetch.mockResolvedValue(createSuccessResponse({
136
+ user_id: userId,
137
+ }));
138
+ const sdk = new Rooguys(apiKey);
139
+
140
+ // Act
141
+ await sdk.users.get(userId);
142
+
143
+ // Assert
144
+ expect(mockFetch).toHaveBeenCalledTimes(1);
145
+ const callUrl = mockFetch.mock.calls[0][0];
146
+ const url = new URL(callUrl);
147
+
148
+ // Verify fields parameter is NOT present
149
+ expect(url.searchParams.has('fields')).toBe(false);
150
+ }
151
+ ),
152
+ { numRuns: 100 }
153
+ );
154
+ });
155
+
156
+ it('should preserve field order in comma-separated list', async () => {
157
+ await fc.assert(
158
+ fc.asyncProperty(
159
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
160
+ fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // User ID
161
+ fieldArray,
162
+ async (apiKey, userId, fields) => {
163
+ // Arrange
164
+ mockFetch.mockClear();
165
+ mockFetch.mockResolvedValue(createSuccessResponse({
166
+ user_id: userId,
167
+ }));
168
+ const sdk = new Rooguys(apiKey);
169
+
170
+ // Act
171
+ await sdk.users.get(userId, { fields });
172
+
173
+ // Assert
174
+ const callUrl = mockFetch.mock.calls[0][0];
175
+ const url = new URL(callUrl);
176
+ const fieldsParam = url.searchParams.get('fields');
177
+ const parsedFields = fieldsParam.split(',');
178
+
179
+ // Fields should be in the same order as provided
180
+ expect(parsedFields).toEqual(fields);
181
+ }
182
+ ),
183
+ { numRuns: 100 }
184
+ );
185
+ });
186
+ });
187
+
188
+ describe('users.search()', () => {
189
+ it('should include fields query parameter in search requests', async () => {
190
+ await fc.assert(
191
+ fc.asyncProperty(
192
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
193
+ fc.string({ minLength: 1, maxLength: 50 }), // Search query
194
+ fieldArray,
195
+ async (apiKey, query, fields) => {
196
+ // Arrange
197
+ mockFetch.mockClear();
198
+ mockFetch.mockResolvedValue(createSuccessResponse({
199
+ users: [],
200
+ pagination: { page: 1, limit: 50, total: 0, totalPages: 0 },
201
+ }));
202
+ const sdk = new Rooguys(apiKey);
203
+
204
+ // Act
205
+ await sdk.users.search(query, { fields });
206
+
207
+ // Assert
208
+ expect(mockFetch).toHaveBeenCalledTimes(1);
209
+ const callUrl = mockFetch.mock.calls[0][0];
210
+ const url = new URL(callUrl);
211
+
212
+ // Verify fields parameter exists
213
+ expect(url.searchParams.has('fields')).toBe(true);
214
+
215
+ // Verify comma-separated format
216
+ const fieldsParam = url.searchParams.get('fields');
217
+ const parsedFields = fieldsParam.split(',');
218
+
219
+ // All requested fields should be in the query
220
+ expect(parsedFields.sort()).toEqual(fields.sort());
221
+ }
222
+ ),
223
+ { numRuns: 100 }
224
+ );
225
+ });
226
+
227
+ it('should include both search query and fields parameters', async () => {
228
+ await fc.assert(
229
+ fc.asyncProperty(
230
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
231
+ fc.string({ minLength: 1, maxLength: 50 }), // Search query
232
+ fieldArray,
233
+ fc.integer({ min: 1, max: 10 }), // Page
234
+ fc.integer({ min: 1, max: 100 }), // Limit
235
+ async (apiKey, query, fields, page, limit) => {
236
+ // Arrange
237
+ mockFetch.mockClear();
238
+ mockFetch.mockResolvedValue(createSuccessResponse({
239
+ users: [],
240
+ pagination: { page, limit, total: 0, totalPages: 0 },
241
+ }));
242
+ const sdk = new Rooguys(apiKey);
243
+
244
+ // Act
245
+ await sdk.users.search(query, { fields, page, limit });
246
+
247
+ // Assert
248
+ const callUrl = mockFetch.mock.calls[0][0];
249
+ const url = new URL(callUrl);
250
+
251
+ // Verify all parameters are present
252
+ expect(url.searchParams.get('q')).toBe(query);
253
+ expect(url.searchParams.get('page')).toBe(String(page));
254
+ expect(url.searchParams.get('limit')).toBe(String(limit));
255
+ expect(url.searchParams.has('fields')).toBe(true);
256
+ }
257
+ ),
258
+ { numRuns: 100 }
259
+ );
260
+ });
261
+ });
262
+
263
+ describe('field selection with special characters', () => {
264
+ it('should properly URL-encode field names with special characters', async () => {
265
+ await fc.assert(
266
+ fc.asyncProperty(
267
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
268
+ fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), // User ID
269
+ async (apiKey, userId) => {
270
+ // Arrange
271
+ mockFetch.mockClear();
272
+ mockFetch.mockResolvedValue(createSuccessResponse({
273
+ user_id: userId,
274
+ }));
275
+ const sdk = new Rooguys(apiKey);
276
+ const fields = ['user_id', 'display_name', 'activity_summary'];
277
+
278
+ // Act
279
+ await sdk.users.get(userId, { fields });
280
+
281
+ // Assert
282
+ const callUrl = mockFetch.mock.calls[0][0];
283
+
284
+ // The URL should be properly encoded
285
+ expect(callUrl).toContain('fields=');
286
+
287
+ // Decode and verify
288
+ const url = new URL(callUrl);
289
+ const fieldsParam = url.searchParams.get('fields');
290
+ expect(fieldsParam).toBe('user_id,display_name,activity_summary');
291
+ }
292
+ ),
293
+ { numRuns: 100 }
294
+ );
295
+ });
296
+ });
297
+ });
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Property-Based Test: Idempotency Key Propagation
3
+ * Feature: sdk-documentation-update, Property 6: Idempotency Key Propagation
4
+ * Validates: Requirements 3.3, 3.4
5
+ *
6
+ * For any request with an idempotency key provided, the SDK SHALL include the
7
+ * X-Idempotency-Key header with the exact value provided. For requests without
8
+ * an idempotency key, the header SHALL NOT be present.
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 6: Idempotency Key Propagation', () => {
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 = () => ({
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: { status: 'queued', message: 'Event accepted for processing' },
38
+ }),
39
+ });
40
+
41
+ const createBatchSuccessResponse = (count) => ({
42
+ ok: true,
43
+ headers: createMockHeaders({
44
+ 'X-RateLimit-Limit': '1000',
45
+ 'X-RateLimit-Remaining': '950',
46
+ 'X-RateLimit-Reset': '1704067200',
47
+ }),
48
+ json: async () => ({
49
+ success: true,
50
+ data: {
51
+ results: Array.from({ length: count }, (_, i) => ({ index: i, status: 'queued' })),
52
+ },
53
+ }),
54
+ });
55
+
56
+ // Generator for idempotency keys - various formats that might be used
57
+ const idempotencyKey = () => fc.oneof(
58
+ fc.uuid(), // UUID format
59
+ fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), // Arbitrary string
60
+ fc.hexaString({ minLength: 16, maxLength: 64 }), // Hex string
61
+ fc.string({ minLength: 10, maxLength: 50 }).map(s => `idem_${s}`), // Prefixed format
62
+ );
63
+
64
+ describe('Single Event Tracking (track)', () => {
65
+ it('should include X-Idempotency-Key header with exact value when provided', async () => {
66
+ await fc.assert(
67
+ fc.asyncProperty(
68
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
69
+ fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), // Event name
70
+ fc.string({ minLength: 1, maxLength: 255 }).filter(s => s.trim().length > 0), // User ID
71
+ idempotencyKey(),
72
+ async (apiKey, eventName, userId, idempotencyKeyValue) => {
73
+ // Arrange
74
+ mockFetch.mockClear();
75
+ mockFetch.mockResolvedValue(createSuccessResponse());
76
+ const sdk = new Rooguys(apiKey);
77
+
78
+ // Act
79
+ await sdk.events.track(eventName, userId, {}, { idempotencyKey: idempotencyKeyValue });
80
+
81
+ // Assert - request was made
82
+ expect(mockFetch).toHaveBeenCalledTimes(1);
83
+
84
+ // Assert - X-Idempotency-Key header is present with exact value
85
+ const callHeaders = mockFetch.mock.calls[0][1].headers;
86
+ expect(callHeaders['X-Idempotency-Key']).toBe(idempotencyKeyValue);
87
+ }
88
+ ),
89
+ { numRuns: 100 }
90
+ );
91
+ });
92
+
93
+ it('should NOT include X-Idempotency-Key header when not provided', async () => {
94
+ await fc.assert(
95
+ fc.asyncProperty(
96
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
97
+ fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), // Event name
98
+ fc.string({ minLength: 1, maxLength: 255 }).filter(s => s.trim().length > 0), // User ID
99
+ async (apiKey, eventName, userId) => {
100
+ // Arrange
101
+ mockFetch.mockClear();
102
+ mockFetch.mockResolvedValue(createSuccessResponse());
103
+ const sdk = new Rooguys(apiKey);
104
+
105
+ // Act
106
+ await sdk.events.track(eventName, userId, {});
107
+
108
+ // Assert - request was made
109
+ expect(mockFetch).toHaveBeenCalledTimes(1);
110
+
111
+ // Assert - X-Idempotency-Key header is NOT present
112
+ const callHeaders = mockFetch.mock.calls[0][1].headers;
113
+ expect(callHeaders['X-Idempotency-Key']).toBeUndefined();
114
+ }
115
+ ),
116
+ { numRuns: 100 }
117
+ );
118
+ });
119
+
120
+ it('should NOT include X-Idempotency-Key header when options object is empty', async () => {
121
+ await fc.assert(
122
+ fc.asyncProperty(
123
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
124
+ fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0), // Event name
125
+ fc.string({ minLength: 1, maxLength: 255 }).filter(s => s.trim().length > 0), // User ID
126
+ async (apiKey, eventName, userId) => {
127
+ // Arrange
128
+ mockFetch.mockClear();
129
+ mockFetch.mockResolvedValue(createSuccessResponse());
130
+ const sdk = new Rooguys(apiKey);
131
+
132
+ // Act - explicitly pass empty options
133
+ await sdk.events.track(eventName, userId, {}, {});
134
+
135
+ // Assert - request was made
136
+ expect(mockFetch).toHaveBeenCalledTimes(1);
137
+
138
+ // Assert - X-Idempotency-Key header is NOT present
139
+ const callHeaders = mockFetch.mock.calls[0][1].headers;
140
+ expect(callHeaders['X-Idempotency-Key']).toBeUndefined();
141
+ }
142
+ ),
143
+ { numRuns: 100 }
144
+ );
145
+ });
146
+ });
147
+
148
+ describe('Batch Event Tracking (trackBatch)', () => {
149
+ it('should include X-Idempotency-Key header with exact value when provided', async () => {
150
+ await fc.assert(
151
+ fc.asyncProperty(
152
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
153
+ fc.integer({ min: 1, max: 50 }), // Number of events
154
+ idempotencyKey(),
155
+ async (apiKey, eventCount, idempotencyKeyValue) => {
156
+ // Arrange
157
+ mockFetch.mockClear();
158
+ mockFetch.mockResolvedValue(createBatchSuccessResponse(eventCount));
159
+ const sdk = new Rooguys(apiKey);
160
+ const events = Array.from({ length: eventCount }, (_, i) => ({
161
+ eventName: `event_${i}`,
162
+ userId: `user_${i}`,
163
+ }));
164
+
165
+ // Act
166
+ await sdk.events.trackBatch(events, { idempotencyKey: idempotencyKeyValue });
167
+
168
+ // Assert - request was made
169
+ expect(mockFetch).toHaveBeenCalledTimes(1);
170
+
171
+ // Assert - X-Idempotency-Key header is present with exact value
172
+ const callHeaders = mockFetch.mock.calls[0][1].headers;
173
+ expect(callHeaders['X-Idempotency-Key']).toBe(idempotencyKeyValue);
174
+ }
175
+ ),
176
+ { numRuns: 100 }
177
+ );
178
+ });
179
+
180
+ it('should NOT include X-Idempotency-Key header when not provided', async () => {
181
+ await fc.assert(
182
+ fc.asyncProperty(
183
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
184
+ fc.integer({ min: 1, max: 50 }), // Number of events
185
+ async (apiKey, eventCount) => {
186
+ // Arrange
187
+ mockFetch.mockClear();
188
+ mockFetch.mockResolvedValue(createBatchSuccessResponse(eventCount));
189
+ const sdk = new Rooguys(apiKey);
190
+ const events = Array.from({ length: eventCount }, (_, i) => ({
191
+ eventName: `event_${i}`,
192
+ userId: `user_${i}`,
193
+ }));
194
+
195
+ // Act
196
+ await sdk.events.trackBatch(events);
197
+
198
+ // Assert - request was made
199
+ expect(mockFetch).toHaveBeenCalledTimes(1);
200
+
201
+ // Assert - X-Idempotency-Key header is NOT present
202
+ const callHeaders = mockFetch.mock.calls[0][1].headers;
203
+ expect(callHeaders['X-Idempotency-Key']).toBeUndefined();
204
+ }
205
+ ),
206
+ { numRuns: 100 }
207
+ );
208
+ });
209
+
210
+ it('should NOT include X-Idempotency-Key header when options object is empty', async () => {
211
+ await fc.assert(
212
+ fc.asyncProperty(
213
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
214
+ fc.integer({ min: 1, max: 50 }), // Number of events
215
+ async (apiKey, eventCount) => {
216
+ // Arrange
217
+ mockFetch.mockClear();
218
+ mockFetch.mockResolvedValue(createBatchSuccessResponse(eventCount));
219
+ const sdk = new Rooguys(apiKey);
220
+ const events = Array.from({ length: eventCount }, (_, i) => ({
221
+ eventName: `event_${i}`,
222
+ userId: `user_${i}`,
223
+ }));
224
+
225
+ // Act - explicitly pass empty options
226
+ await sdk.events.trackBatch(events, {});
227
+
228
+ // Assert - request was made
229
+ expect(mockFetch).toHaveBeenCalledTimes(1);
230
+
231
+ // Assert - X-Idempotency-Key header is NOT present
232
+ const callHeaders = mockFetch.mock.calls[0][1].headers;
233
+ expect(callHeaders['X-Idempotency-Key']).toBeUndefined();
234
+ }
235
+ ),
236
+ { numRuns: 100 }
237
+ );
238
+ });
239
+ });
240
+
241
+ describe('Idempotency Key Value Preservation', () => {
242
+ it('should preserve special characters in idempotency key', async () => {
243
+ await fc.assert(
244
+ fc.asyncProperty(
245
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
246
+ // Generate keys with special characters
247
+ fc.string({ minLength: 1, maxLength: 50 }).map(s => `key-${s}-!@#$%^&*()`),
248
+ async (apiKey, idempotencyKeyValue) => {
249
+ // Arrange
250
+ mockFetch.mockClear();
251
+ mockFetch.mockResolvedValue(createSuccessResponse());
252
+ const sdk = new Rooguys(apiKey);
253
+
254
+ // Act
255
+ await sdk.events.track('test_event', 'user_123', {}, { idempotencyKey: idempotencyKeyValue });
256
+
257
+ // Assert - exact value is preserved
258
+ const callHeaders = mockFetch.mock.calls[0][1].headers;
259
+ expect(callHeaders['X-Idempotency-Key']).toBe(idempotencyKeyValue);
260
+ }
261
+ ),
262
+ { numRuns: 100 }
263
+ );
264
+ });
265
+
266
+ it('should preserve unicode characters in idempotency key', async () => {
267
+ await fc.assert(
268
+ fc.asyncProperty(
269
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
270
+ // Generate keys with unicode characters
271
+ fc.unicodeString({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
272
+ async (apiKey, idempotencyKeyValue) => {
273
+ // Arrange
274
+ mockFetch.mockClear();
275
+ mockFetch.mockResolvedValue(createSuccessResponse());
276
+ const sdk = new Rooguys(apiKey);
277
+
278
+ // Act
279
+ await sdk.events.track('test_event', 'user_123', {}, { idempotencyKey: idempotencyKeyValue });
280
+
281
+ // Assert - exact value is preserved
282
+ const callHeaders = mockFetch.mock.calls[0][1].headers;
283
+ expect(callHeaders['X-Idempotency-Key']).toBe(idempotencyKeyValue);
284
+ }
285
+ ),
286
+ { numRuns: 100 }
287
+ );
288
+ });
289
+
290
+ it('should preserve whitespace in idempotency key', async () => {
291
+ await fc.assert(
292
+ fc.asyncProperty(
293
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
294
+ // Generate keys with whitespace
295
+ fc.tuple(
296
+ fc.string({ minLength: 1, maxLength: 20 }),
297
+ fc.constantFrom(' ', ' ', '\t'),
298
+ fc.string({ minLength: 1, maxLength: 20 })
299
+ ).map(([a, ws, b]) => `${a}${ws}${b}`),
300
+ async (apiKey, idempotencyKeyValue) => {
301
+ // Arrange
302
+ mockFetch.mockClear();
303
+ mockFetch.mockResolvedValue(createSuccessResponse());
304
+ const sdk = new Rooguys(apiKey);
305
+
306
+ // Act
307
+ await sdk.events.track('test_event', 'user_123', {}, { idempotencyKey: idempotencyKeyValue });
308
+
309
+ // Assert - exact value is preserved including whitespace
310
+ const callHeaders = mockFetch.mock.calls[0][1].headers;
311
+ expect(callHeaders['X-Idempotency-Key']).toBe(idempotencyKeyValue);
312
+ }
313
+ ),
314
+ { numRuns: 100 }
315
+ );
316
+ });
317
+ });
318
+
319
+ describe('Consistency Across Methods', () => {
320
+ it('should handle idempotency key consistently between track and trackBatch', async () => {
321
+ await fc.assert(
322
+ fc.asyncProperty(
323
+ fc.string({ minLength: 10, maxLength: 100 }), // API key
324
+ idempotencyKey(),
325
+ async (apiKey, idempotencyKeyValue) => {
326
+ // Arrange
327
+ mockFetch.mockClear();
328
+ mockFetch.mockResolvedValue(createSuccessResponse());
329
+ const sdk = new Rooguys(apiKey);
330
+
331
+ // Act - track single event
332
+ await sdk.events.track('test_event', 'user_123', {}, { idempotencyKey: idempotencyKeyValue });
333
+ const trackHeaders = mockFetch.mock.calls[0][1].headers;
334
+
335
+ // Reset and test batch
336
+ mockFetch.mockClear();
337
+ mockFetch.mockResolvedValue(createBatchSuccessResponse(1));
338
+ await sdk.events.trackBatch([{ eventName: 'test_event', userId: 'user_123' }], { idempotencyKey: idempotencyKeyValue });
339
+ const batchHeaders = mockFetch.mock.calls[0][1].headers;
340
+
341
+ // Assert - both methods handle idempotency key the same way
342
+ expect(trackHeaders['X-Idempotency-Key']).toBe(idempotencyKeyValue);
343
+ expect(batchHeaders['X-Idempotency-Key']).toBe(idempotencyKeyValue);
344
+ }
345
+ ),
346
+ { numRuns: 100 }
347
+ );
348
+ });
349
+ });
350
+ });