@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,5 @@
1
- import Rooguys from '../../index';
2
- import { mockSuccessResponse, mockErrorResponse } from '../utils/mockClient';
1
+ import Rooguys, { ValidationError } from '../../index';
2
+ import { mockStandardizedSuccess, mockErrorResponse } from '../utils/mockClient';
3
3
  import { mockResponses, mockErrors } from '../fixtures/responses';
4
4
 
5
5
  describe('Users Resource', () => {
@@ -11,17 +11,171 @@ describe('Users Resource', () => {
11
11
  global.fetch.mockClear();
12
12
  });
13
13
 
14
+ describe('create', () => {
15
+ it('should create a new user', async () => {
16
+ const userData = {
17
+ userId: 'new_user_123',
18
+ displayName: 'Test User',
19
+ email: 'test@example.com',
20
+ };
21
+ global.fetch.mockResolvedValue(mockStandardizedSuccess({
22
+ user_id: 'new_user_123',
23
+ display_name: 'Test User',
24
+ email: 'test@example.com',
25
+ points: 0,
26
+ }));
27
+
28
+ const result = await client.users.create(userData);
29
+
30
+ expect(global.fetch).toHaveBeenCalledWith(
31
+ expect.stringContaining('/users'),
32
+ expect.objectContaining({
33
+ method: 'POST',
34
+ body: expect.stringContaining('new_user_123'),
35
+ })
36
+ );
37
+ expect(result.user_id).toBe('new_user_123');
38
+ });
39
+
40
+ it('should throw ValidationError when userId is missing', async () => {
41
+ await expect(client.users.create({})).rejects.toThrow(ValidationError);
42
+ await expect(client.users.create({})).rejects.toThrow('User ID is required');
43
+ });
44
+
45
+ it('should throw ValidationError for invalid email format', async () => {
46
+ await expect(client.users.create({
47
+ userId: 'user_123',
48
+ email: 'invalid-email',
49
+ })).rejects.toThrow(ValidationError);
50
+ await expect(client.users.create({
51
+ userId: 'user_123',
52
+ email: 'invalid-email',
53
+ })).rejects.toThrow('Invalid email format');
54
+ });
55
+
56
+ it('should allow creation without email', async () => {
57
+ global.fetch.mockResolvedValue(mockStandardizedSuccess({
58
+ user_id: 'user_123',
59
+ points: 0,
60
+ }));
61
+
62
+ const result = await client.users.create({ userId: 'user_123' });
63
+ expect(result.user_id).toBe('user_123');
64
+ });
65
+ });
66
+
67
+ describe('update', () => {
68
+ it('should update an existing user', async () => {
69
+ global.fetch.mockResolvedValue(mockStandardizedSuccess({
70
+ user_id: 'user_123',
71
+ display_name: 'Updated Name',
72
+ points: 100,
73
+ }));
74
+
75
+ const result = await client.users.update('user_123', { displayName: 'Updated Name' });
76
+
77
+ expect(global.fetch).toHaveBeenCalledWith(
78
+ expect.stringContaining('/users/user_123'),
79
+ expect.objectContaining({
80
+ method: 'PATCH',
81
+ })
82
+ );
83
+ expect(result.display_name).toBe('Updated Name');
84
+ });
85
+
86
+ it('should throw ValidationError when userId is missing', async () => {
87
+ await expect(client.users.update('', { displayName: 'Test' })).rejects.toThrow(ValidationError);
88
+ });
89
+
90
+ it('should throw ValidationError for invalid email format', async () => {
91
+ await expect(client.users.update('user_123', {
92
+ email: 'not-an-email',
93
+ })).rejects.toThrow(ValidationError);
94
+ });
95
+
96
+ it('should only send provided fields (partial update)', async () => {
97
+ global.fetch.mockResolvedValue(mockStandardizedSuccess({
98
+ user_id: 'user_123',
99
+ display_name: 'New Name',
100
+ }));
101
+
102
+ await client.users.update('user_123', { displayName: 'New Name' });
103
+
104
+ const callBody = JSON.parse(global.fetch.mock.calls[0][1].body);
105
+ expect(callBody).toEqual({ display_name: 'New Name' });
106
+ expect(callBody.email).toBeUndefined();
107
+ });
108
+ });
109
+
110
+ describe('createBatch', () => {
111
+ it('should create multiple users', async () => {
112
+ global.fetch.mockResolvedValue(mockStandardizedSuccess({
113
+ results: [
114
+ { index: 0, status: 'created', user_id: 'user_1' },
115
+ { index: 1, status: 'created', user_id: 'user_2' },
116
+ ],
117
+ }));
118
+
119
+ const users = [
120
+ { userId: 'user_1', displayName: 'User 1' },
121
+ { userId: 'user_2', displayName: 'User 2' },
122
+ ];
123
+
124
+ const result = await client.users.createBatch(users);
125
+
126
+ expect(global.fetch).toHaveBeenCalledWith(
127
+ expect.stringContaining('/users/batch'),
128
+ expect.objectContaining({ method: 'POST' })
129
+ );
130
+ expect(result.results).toHaveLength(2);
131
+ });
132
+
133
+ it('should throw ValidationError for non-array input', async () => {
134
+ await expect(client.users.createBatch('not-an-array')).rejects.toThrow(ValidationError);
135
+ await expect(client.users.createBatch('not-an-array')).rejects.toThrow('Users must be an array');
136
+ });
137
+
138
+ it('should throw ValidationError for empty array', async () => {
139
+ await expect(client.users.createBatch([])).rejects.toThrow(ValidationError);
140
+ await expect(client.users.createBatch([])).rejects.toThrow('Users array cannot be empty');
141
+ });
142
+
143
+ it('should throw ValidationError for batch exceeding 100 users', async () => {
144
+ const users = Array.from({ length: 101 }, (_, i) => ({ userId: `user_${i}` }));
145
+ await expect(client.users.createBatch(users)).rejects.toThrow(ValidationError);
146
+ await expect(client.users.createBatch(users)).rejects.toThrow('Batch size exceeds maximum of 100 users');
147
+ });
148
+
149
+ it('should throw ValidationError when a user is missing userId', async () => {
150
+ const users = [
151
+ { userId: 'user_1' },
152
+ { displayName: 'Missing ID' },
153
+ ];
154
+ await expect(client.users.createBatch(users)).rejects.toThrow(ValidationError);
155
+ await expect(client.users.createBatch(users)).rejects.toThrow('User at index 1: User ID is required');
156
+ });
157
+
158
+ it('should throw ValidationError for invalid email in batch', async () => {
159
+ const users = [
160
+ { userId: 'user_1', email: 'valid@example.com' },
161
+ { userId: 'user_2', email: 'invalid-email' },
162
+ ];
163
+ await expect(client.users.createBatch(users)).rejects.toThrow(ValidationError);
164
+ await expect(client.users.createBatch(users)).rejects.toThrow('User at index 1: Invalid email format');
165
+ });
166
+ });
167
+
14
168
  describe('get', () => {
15
169
  it('should get a user profile', async () => {
16
- global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.userProfile));
170
+ global.fetch.mockResolvedValue(mockStandardizedSuccess(mockResponses.userProfile));
17
171
 
18
172
  const result = await client.users.get('user_123');
19
173
 
20
174
  expect(global.fetch).toHaveBeenCalledWith(
21
- expect.stringContaining('/user/user_123'),
175
+ expect.stringContaining('/users/user_123'),
22
176
  expect.any(Object)
23
177
  );
24
- expect(result).toEqual(mockResponses.userProfile);
178
+ expect(result.user_id).toBe('user123');
25
179
  });
26
180
 
27
181
  it('should throw 404 error when user not found', async () => {
@@ -31,20 +185,129 @@ describe('Users Resource', () => {
31
185
 
32
186
  await expect(client.users.get('nonexistent_user')).rejects.toThrow("User 'user123' does not exist in this project");
33
187
  });
188
+
189
+ it('should support field selection', async () => {
190
+ global.fetch.mockResolvedValue(mockStandardizedSuccess({
191
+ user_id: 'user_123',
192
+ points: 100,
193
+ }));
194
+
195
+ await client.users.get('user_123', { fields: ['user_id', 'points'] });
196
+
197
+ const callUrl = global.fetch.mock.calls[0][0];
198
+ expect(callUrl).toContain('fields=user_id%2Cpoints');
199
+ });
200
+
201
+ it('should parse activity summary in profile', async () => {
202
+ global.fetch.mockResolvedValue(mockStandardizedSuccess({
203
+ user_id: 'user_123',
204
+ activity_summary: {
205
+ last_event_at: '2024-01-15T10:30:00Z',
206
+ event_count: 150,
207
+ days_active: 30,
208
+ },
209
+ }));
210
+
211
+ const result = await client.users.get('user_123');
212
+
213
+ expect(result.activitySummary).toBeDefined();
214
+ expect(result.activitySummary.eventCount).toBe(150);
215
+ expect(result.activitySummary.daysActive).toBe(30);
216
+ expect(result.activitySummary.lastEventAt).toBeInstanceOf(Date);
217
+ });
218
+
219
+ it('should parse streak info in profile', async () => {
220
+ global.fetch.mockResolvedValue(mockStandardizedSuccess({
221
+ user_id: 'user_123',
222
+ streak: {
223
+ current_streak: 5,
224
+ longest_streak: 10,
225
+ last_activity_at: '2024-01-15T10:30:00Z',
226
+ },
227
+ }));
228
+
229
+ const result = await client.users.get('user_123');
230
+
231
+ expect(result.streak).toBeDefined();
232
+ expect(result.streak.currentStreak).toBe(5);
233
+ expect(result.streak.longestStreak).toBe(10);
234
+ });
235
+
236
+ it('should parse inventory summary in profile', async () => {
237
+ global.fetch.mockResolvedValue(mockStandardizedSuccess({
238
+ user_id: 'user_123',
239
+ inventory: {
240
+ item_count: 5,
241
+ active_effects: ['double_points', 'shield'],
242
+ },
243
+ }));
244
+
245
+ const result = await client.users.get('user_123');
246
+
247
+ expect(result.inventory).toBeDefined();
248
+ expect(result.inventory.itemCount).toBe(5);
249
+ expect(result.inventory.activeEffects).toEqual(['double_points', 'shield']);
250
+ });
251
+ });
252
+
253
+ describe('search', () => {
254
+ it('should search users with query', async () => {
255
+ global.fetch.mockResolvedValue(mockStandardizedSuccess({
256
+ users: [
257
+ { user_id: 'user_1', display_name: 'John' },
258
+ { user_id: 'user_2', display_name: 'Johnny' },
259
+ ],
260
+ pagination: { page: 1, limit: 50, total: 2, totalPages: 1 },
261
+ }));
262
+
263
+ const result = await client.users.search('john');
264
+
265
+ expect(global.fetch).toHaveBeenCalledWith(
266
+ expect.stringContaining('/users/search'),
267
+ expect.any(Object)
268
+ );
269
+ const callUrl = global.fetch.mock.calls[0][0];
270
+ expect(callUrl).toContain('q=john');
271
+ expect(result.users).toHaveLength(2);
272
+ });
273
+
274
+ it('should support pagination options', async () => {
275
+ global.fetch.mockResolvedValue(mockStandardizedSuccess({
276
+ users: [],
277
+ pagination: { page: 2, limit: 10, total: 0, totalPages: 0 },
278
+ }));
279
+
280
+ await client.users.search('test', { page: 2, limit: 10 });
281
+
282
+ const callUrl = global.fetch.mock.calls[0][0];
283
+ expect(callUrl).toContain('page=2');
284
+ expect(callUrl).toContain('limit=10');
285
+ });
286
+
287
+ it('should support field selection in search', async () => {
288
+ global.fetch.mockResolvedValue(mockStandardizedSuccess({
289
+ users: [],
290
+ pagination: { page: 1, limit: 50, total: 0, totalPages: 0 },
291
+ }));
292
+
293
+ await client.users.search('test', { fields: ['user_id', 'points'] });
294
+
295
+ const callUrl = global.fetch.mock.calls[0][0];
296
+ expect(callUrl).toContain('fields=user_id%2Cpoints');
297
+ });
34
298
  });
35
299
 
36
300
  describe('getBulk', () => {
37
301
  it('should get multiple user profiles', async () => {
38
- global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.bulkUsersResponse));
302
+ global.fetch.mockResolvedValue(mockStandardizedSuccess(mockResponses.bulkUsersResponse));
39
303
 
40
304
  const result = await client.users.getBulk(['user1', 'user2']);
41
305
 
42
- expect(result).toEqual(mockResponses.bulkUsersResponse);
43
306
  expect(result.users).toHaveLength(2);
44
307
  });
45
308
 
46
309
  it('should handle empty results', async () => {
47
- global.fetch.mockResolvedValue(mockSuccessResponse({ users: [] }));
310
+ global.fetch.mockResolvedValue(mockStandardizedSuccess({ users: [] }));
48
311
 
49
312
  const result = await client.users.getBulk(['nonexistent1']);
50
313
 
@@ -55,7 +318,7 @@ describe('Users Resource', () => {
55
318
  describe('getBadges', () => {
56
319
  it('should get user badges', async () => {
57
320
  global.fetch.mockResolvedValue(
58
- mockSuccessResponse({ badges: mockResponses.userProfile.badges })
321
+ mockStandardizedSuccess({ badges: mockResponses.userProfile.badges })
59
322
  );
60
323
 
61
324
  const result = await client.users.getBadges('user_123');
@@ -66,7 +329,7 @@ describe('Users Resource', () => {
66
329
 
67
330
  describe('getRank', () => {
68
331
  it('should get user rank with default timeframe', async () => {
69
- global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.userRankResponse));
332
+ global.fetch.mockResolvedValue(mockStandardizedSuccess(mockResponses.userRankResponse));
70
333
 
71
334
  const result = await client.users.getRank('user_123');
72
335
 
@@ -76,7 +339,7 @@ describe('Users Resource', () => {
76
339
  });
77
340
 
78
341
  it('should get user rank with weekly timeframe', async () => {
79
- global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.userRankResponse));
342
+ global.fetch.mockResolvedValue(mockStandardizedSuccess(mockResponses.userRankResponse));
80
343
 
81
344
  await client.users.getRank('user_123', 'weekly');
82
345
 
@@ -87,7 +350,7 @@ describe('Users Resource', () => {
87
350
 
88
351
  describe('submitAnswers', () => {
89
352
  it('should submit questionnaire answers', async () => {
90
- global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.answerSubmissionResponse));
353
+ global.fetch.mockResolvedValue(mockStandardizedSuccess(mockResponses.answerSubmissionResponse));
91
354
 
92
355
  const answers = [
93
356
  { question_id: 'q1', answer_option_id: 'a1' },
@@ -77,4 +77,91 @@ export const arbitraries = {
77
77
  }),
78
78
  { minLength: 1, maxLength: 20 }
79
79
  ),
80
+
81
+ // Request ID
82
+ requestId: () => fc.string({ minLength: 10, maxLength: 50 }).map(s => `req_${s}`),
83
+
84
+ // HTTP status codes
85
+ httpStatusCode: () => fc.constantFrom(200, 201, 400, 401, 403, 404, 409, 429, 500, 502, 503),
86
+
87
+ // Error status codes only
88
+ errorStatusCode: () => fc.constantFrom(400, 401, 403, 404, 409, 429, 500, 502, 503),
89
+
90
+ // Rate limit info
91
+ rateLimitInfo: () => fc.record({
92
+ limit: fc.integer({ min: 100, max: 10000 }),
93
+ remaining: fc.integer({ min: 0, max: 10000 }),
94
+ reset: fc.integer({ min: 1700000000, max: 1800000000 }),
95
+ }).filter(r => r.remaining <= r.limit),
96
+
97
+ // Standardized success response
98
+ successResponse: () => fc.record({
99
+ success: fc.constant(true),
100
+ data: fc.dictionary(
101
+ fc.string({ minLength: 1, maxLength: 20 }),
102
+ fc.oneof(
103
+ fc.string(),
104
+ fc.integer(),
105
+ fc.boolean(),
106
+ fc.constant(null),
107
+ fc.array(fc.string(), { maxLength: 5 })
108
+ )
109
+ ),
110
+ request_id: arbitraries.requestId(),
111
+ }),
112
+
113
+ // Standardized error response
114
+ errorResponse: () => fc.record({
115
+ success: fc.constant(false),
116
+ error: fc.record({
117
+ code: fc.constantFrom(
118
+ 'VALIDATION_ERROR',
119
+ 'INVALID_API_KEY',
120
+ 'NOT_FOUND',
121
+ 'CONFLICT',
122
+ 'RATE_LIMIT_EXCEEDED',
123
+ 'INTERNAL_ERROR'
124
+ ),
125
+ message: fc.string({ minLength: 5, maxLength: 100 }),
126
+ details: fc.option(
127
+ fc.array(
128
+ fc.record({
129
+ field: fc.string({ minLength: 1, maxLength: 30 }),
130
+ message: fc.string({ minLength: 5, maxLength: 100 }),
131
+ }),
132
+ { minLength: 1, maxLength: 5 }
133
+ ),
134
+ { nil: null }
135
+ ),
136
+ }),
137
+ request_id: arbitraries.requestId(),
138
+ }),
139
+
140
+ // Pagination info
141
+ paginationInfo: () => fc.record({
142
+ page: fc.integer({ min: 1, max: 100 }),
143
+ limit: fc.integer({ min: 1, max: 100 }),
144
+ total: fc.integer({ min: 0, max: 10000 }),
145
+ totalPages: fc.integer({ min: 0, max: 100 }),
146
+ }),
147
+
148
+ // Valid API response (either success or error)
149
+ validApiResponse: () => fc.oneof(
150
+ arbitraries.successResponse(),
151
+ arbitraries.errorResponse()
152
+ ),
153
+
154
+ // Error code to status mapping
155
+ errorCodeStatusPair: () => fc.constantFrom(
156
+ { code: 'VALIDATION_ERROR', status: 400 },
157
+ { code: 'INVALID_API_KEY', status: 401 },
158
+ { code: 'FORBIDDEN', status: 403 },
159
+ { code: 'NOT_FOUND', status: 404 },
160
+ { code: 'USER_NOT_FOUND', status: 404 },
161
+ { code: 'CONFLICT', status: 409 },
162
+ { code: 'USER_EXISTS', status: 409 },
163
+ { code: 'RATE_LIMIT_EXCEEDED', status: 429 },
164
+ { code: 'INTERNAL_ERROR', status: 500 },
165
+ { code: 'SERVER_ERROR', status: 500 }
166
+ ),
80
167
  };
@@ -7,30 +7,70 @@ export function createMockFetch() {
7
7
  return jest.fn();
8
8
  }
9
9
 
10
- export function mockSuccessResponse(data, status = 200) {
10
+ export function mockSuccessResponse(data, status = 200, headers = {}) {
11
+ const defaultHeaders = {
12
+ 'X-RateLimit-Limit': '1000',
13
+ 'X-RateLimit-Remaining': '950',
14
+ 'X-RateLimit-Reset': '1704067200',
15
+ 'X-Request-Id': 'req_test123',
16
+ ...headers,
17
+ };
18
+
11
19
  return Promise.resolve({
12
20
  ok: true,
13
21
  status,
22
+ headers: createMockHeaders(defaultHeaders),
14
23
  json: () => Promise.resolve(data),
15
24
  text: () => Promise.resolve(JSON.stringify(data)),
16
25
  });
17
26
  }
18
27
 
19
- export function mockErrorResponse(status, message, details) {
28
+ export function mockStandardizedSuccess(data, requestId = 'req_test123', headers = {}) {
29
+ const responseBody = {
30
+ success: true,
31
+ data,
32
+ request_id: requestId,
33
+ };
34
+ return mockSuccessResponse(responseBody, 200, { 'X-Request-Id': requestId, ...headers });
35
+ }
36
+
37
+ export function mockErrorResponse(status, message, details = null, headers = {}) {
20
38
  const errorData = {
21
- error: message,
22
- ...(details && { details }),
39
+ success: false,
40
+ error: {
41
+ code: getErrorCodeForStatus(status),
42
+ message,
43
+ ...(details && { details }),
44
+ },
45
+ request_id: headers['X-Request-Id'] || 'req_error123',
23
46
  };
24
-
47
+
48
+ const defaultHeaders = {
49
+ 'X-RateLimit-Limit': '1000',
50
+ 'X-RateLimit-Remaining': '950',
51
+ 'X-RateLimit-Reset': '1704067200',
52
+ ...headers,
53
+ };
54
+
25
55
  return Promise.resolve({
26
56
  ok: false,
27
57
  status,
28
58
  statusText: message,
59
+ headers: createMockHeaders(defaultHeaders),
29
60
  json: () => Promise.resolve(errorData),
30
61
  text: () => Promise.resolve(JSON.stringify(errorData)),
31
62
  });
32
63
  }
33
64
 
65
+ export function mockRateLimitResponse(retryAfter = 60) {
66
+ return mockErrorResponse(429, 'Rate limit exceeded', null, {
67
+ 'Retry-After': String(retryAfter),
68
+ 'X-RateLimit-Limit': '1000',
69
+ 'X-RateLimit-Remaining': '0',
70
+ 'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + retryAfter),
71
+ });
72
+ }
73
+
34
74
  export function mockNetworkError(message = 'Network error') {
35
75
  return new Error(message);
36
76
  }
@@ -40,3 +80,29 @@ export function mockTimeoutError() {
40
80
  error.name = 'AbortError';
41
81
  return error;
42
82
  }
83
+
84
+ function getErrorCodeForStatus(status) {
85
+ const codeMap = {
86
+ 400: 'VALIDATION_ERROR',
87
+ 401: 'INVALID_API_KEY',
88
+ 403: 'FORBIDDEN',
89
+ 404: 'NOT_FOUND',
90
+ 409: 'CONFLICT',
91
+ 429: 'RATE_LIMIT_EXCEEDED',
92
+ 500: 'INTERNAL_ERROR',
93
+ 502: 'BAD_GATEWAY',
94
+ 503: 'SERVICE_UNAVAILABLE',
95
+ };
96
+ return codeMap[status] || 'UNKNOWN_ERROR';
97
+ }
98
+
99
+ /**
100
+ * Create mock headers object that mimics the Headers API
101
+ */
102
+ export function createMockHeaders(headersObj = {}) {
103
+ return {
104
+ get: (name) => headersObj[name] || headersObj[name.toLowerCase()] || null,
105
+ has: (name) => name in headersObj || name.toLowerCase() in headersObj,
106
+ entries: () => Object.entries(headersObj),
107
+ };
108
+ }
package/src/errors.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Rooguys SDK Error Classes
3
+ * Typed error classes for different API error scenarios
4
+ */
5
+
6
+ /**
7
+ * Base error class for all Rooguys SDK errors
8
+ */
9
+ export class RooguysError extends Error {
10
+ constructor(message, { code = 'UNKNOWN_ERROR', requestId = null, statusCode = 500 } = {}) {
11
+ super(message);
12
+ this.name = 'RooguysError';
13
+ this.code = code;
14
+ this.requestId = requestId;
15
+ this.statusCode = statusCode;
16
+ }
17
+
18
+ toJSON() {
19
+ return {
20
+ name: this.name,
21
+ message: this.message,
22
+ code: this.code,
23
+ requestId: this.requestId,
24
+ statusCode: this.statusCode,
25
+ };
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Validation error (HTTP 400)
31
+ * Thrown when request validation fails
32
+ */
33
+ export class ValidationError extends RooguysError {
34
+ constructor(message, { code = 'VALIDATION_ERROR', requestId = null, fieldErrors = null } = {}) {
35
+ super(message, { code, requestId, statusCode: 400 });
36
+ this.name = 'ValidationError';
37
+ this.fieldErrors = fieldErrors;
38
+ }
39
+
40
+ toJSON() {
41
+ return {
42
+ ...super.toJSON(),
43
+ fieldErrors: this.fieldErrors,
44
+ };
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Authentication error (HTTP 401)
50
+ * Thrown when API key is invalid or missing
51
+ */
52
+ export class AuthenticationError extends RooguysError {
53
+ constructor(message, { code = 'AUTHENTICATION_ERROR', requestId = null } = {}) {
54
+ super(message, { code, requestId, statusCode: 401 });
55
+ this.name = 'AuthenticationError';
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Forbidden error (HTTP 403)
61
+ * Thrown when access is denied
62
+ */
63
+ export class ForbiddenError extends RooguysError {
64
+ constructor(message, { code = 'FORBIDDEN', requestId = null } = {}) {
65
+ super(message, { code, requestId, statusCode: 403 });
66
+ this.name = 'ForbiddenError';
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Not found error (HTTP 404)
72
+ * Thrown when requested resource doesn't exist
73
+ */
74
+ export class NotFoundError extends RooguysError {
75
+ constructor(message, { code = 'NOT_FOUND', requestId = null } = {}) {
76
+ super(message, { code, requestId, statusCode: 404 });
77
+ this.name = 'NotFoundError';
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Conflict error (HTTP 409)
83
+ * Thrown when resource already exists or state conflict
84
+ */
85
+ export class ConflictError extends RooguysError {
86
+ constructor(message, { code = 'CONFLICT', requestId = null } = {}) {
87
+ super(message, { code, requestId, statusCode: 409 });
88
+ this.name = 'ConflictError';
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Rate limit error (HTTP 429)
94
+ * Thrown when rate limit is exceeded
95
+ */
96
+ export class RateLimitError extends RooguysError {
97
+ constructor(message, { code = 'RATE_LIMIT_EXCEEDED', requestId = null, retryAfter = 60 } = {}) {
98
+ super(message, { code, requestId, statusCode: 429 });
99
+ this.name = 'RateLimitError';
100
+ this.retryAfter = retryAfter;
101
+ }
102
+
103
+ toJSON() {
104
+ return {
105
+ ...super.toJSON(),
106
+ retryAfter: this.retryAfter,
107
+ };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Server error (HTTP 500+)
113
+ * Thrown when server encounters an error
114
+ */
115
+ export class ServerError extends RooguysError {
116
+ constructor(message, { code = 'SERVER_ERROR', requestId = null, statusCode = 500 } = {}) {
117
+ super(message, { code, requestId, statusCode });
118
+ this.name = 'ServerError';
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Map HTTP status code to appropriate error class
124
+ * @param {number} status - HTTP status code
125
+ * @param {object} errorBody - Error response body
126
+ * @param {string} requestId - Request ID from response
127
+ * @param {object} headers - Response headers
128
+ * @returns {RooguysError} Appropriate error instance
129
+ */
130
+ export function mapStatusToError(status, errorBody, requestId, headers = {}) {
131
+ const message = errorBody?.error?.message || errorBody?.error || errorBody?.message || 'An error occurred';
132
+ const code = errorBody?.error?.code || errorBody?.code || 'UNKNOWN_ERROR';
133
+ const fieldErrors = errorBody?.error?.details || errorBody?.details || null;
134
+
135
+ switch (status) {
136
+ case 400:
137
+ return new ValidationError(message, { code, requestId, fieldErrors });
138
+ case 401:
139
+ return new AuthenticationError(message, { code, requestId });
140
+ case 403:
141
+ return new ForbiddenError(message, { code, requestId });
142
+ case 404:
143
+ return new NotFoundError(message, { code, requestId });
144
+ case 409:
145
+ return new ConflictError(message, { code, requestId });
146
+ case 429: {
147
+ const retryAfter = parseInt(headers['retry-after'] || headers['Retry-After'] || '60', 10);
148
+ return new RateLimitError(message, { code, requestId, retryAfter });
149
+ }
150
+ default:
151
+ if (status >= 500) {
152
+ return new ServerError(message, { code, requestId, statusCode: status });
153
+ }
154
+ return new RooguysError(message, { code, requestId, statusCode: status });
155
+ }
156
+ }