@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.
- package/README.md +342 -141
- package/package.json +1 -1
- package/src/__tests__/fixtures/responses.js +249 -0
- package/src/__tests__/property/batch-event-validation.property.test.js +225 -0
- package/src/__tests__/property/email-validation.property.test.js +272 -0
- package/src/__tests__/property/error-mapping.property.test.js +506 -0
- package/src/__tests__/property/field-selection.property.test.js +297 -0
- package/src/__tests__/property/idempotency-key.property.test.js +350 -0
- package/src/__tests__/property/leaderboard-filter.property.test.js +585 -0
- package/src/__tests__/property/partial-update.property.test.js +251 -0
- package/src/__tests__/property/rate-limit-error.property.test.js +276 -0
- package/src/__tests__/property/rate-limit-extraction.property.test.js +193 -0
- package/src/__tests__/property/request-construction.property.test.js +20 -28
- package/src/__tests__/property/response-format.property.test.js +418 -0
- package/src/__tests__/property/response-parsing.property.test.js +16 -21
- package/src/__tests__/property/timestamp-validation.property.test.js +345 -0
- package/src/__tests__/unit/aha.test.js +57 -26
- package/src/__tests__/unit/config.test.js +7 -1
- package/src/__tests__/unit/errors.test.js +6 -8
- package/src/__tests__/unit/events.test.js +253 -14
- package/src/__tests__/unit/leaderboards.test.js +249 -0
- package/src/__tests__/unit/questionnaires.test.js +6 -6
- package/src/__tests__/unit/users.test.js +275 -12
- package/src/__tests__/utils/generators.js +87 -0
- package/src/__tests__/utils/mockClient.js +71 -5
- package/src/errors.js +156 -0
- package/src/http-client.js +276 -0
- 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
|
+
});
|