@rooguys/js 0.1.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.
@@ -0,0 +1,70 @@
1
+ import Rooguys from '../../index';
2
+ import { mockSuccessResponse, mockErrorResponse } from '../utils/mockClient';
3
+ import { mockResponses, mockErrors } from '../fixtures/responses';
4
+
5
+ describe('Levels Resource', () => {
6
+ let client;
7
+ const apiKey = 'test-api-key';
8
+
9
+ beforeEach(() => {
10
+ client = new Rooguys(apiKey);
11
+ global.fetch.mockClear();
12
+ });
13
+
14
+ describe('list', () => {
15
+ it('should list levels with default parameters', async () => {
16
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.levelsListResponse));
17
+
18
+ const result = await client.levels.list();
19
+
20
+ const callUrl = global.fetch.mock.calls[0][0];
21
+ expect(callUrl).toContain('page=1');
22
+ expect(callUrl).toContain('limit=50');
23
+ expect(result.levels).toHaveLength(2);
24
+ });
25
+
26
+ it('should list levels with custom pagination', async () => {
27
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.levelsListResponse));
28
+
29
+ await client.levels.list(2, 25);
30
+
31
+ const callUrl = global.fetch.mock.calls[0][0];
32
+ expect(callUrl).toContain('page=2');
33
+ expect(callUrl).toContain('limit=25');
34
+ });
35
+
36
+ it('should handle empty levels list', async () => {
37
+ const emptyResponse = {
38
+ levels: [],
39
+ pagination: { page: 1, limit: 50, total: 0, totalPages: 0 },
40
+ };
41
+ global.fetch.mockResolvedValue(mockSuccessResponse(emptyResponse));
42
+
43
+ const result = await client.levels.list();
44
+
45
+ expect(result.levels).toEqual([]);
46
+ });
47
+
48
+ it('should handle levels with all fields', async () => {
49
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.levelsListResponse));
50
+
51
+ const result = await client.levels.list();
52
+
53
+ const level = result.levels[0];
54
+ expect(level.id).toBeDefined();
55
+ expect(level.name).toBeDefined();
56
+ expect(level.level_number).toBeDefined();
57
+ expect(level.points_required).toBeDefined();
58
+ });
59
+
60
+ it('should throw error for invalid pagination', async () => {
61
+ global.fetch.mockResolvedValue(
62
+ mockErrorResponse(400, mockErrors.invalidPaginationError.message)
63
+ );
64
+
65
+ await expect(client.levels.list(1, 150)).rejects.toThrow(
66
+ 'Limit must be between 1 and 100'
67
+ );
68
+ });
69
+ });
70
+ });
@@ -0,0 +1,68 @@
1
+ import Rooguys from '../../index';
2
+ import { mockSuccessResponse, mockErrorResponse } from '../utils/mockClient';
3
+ import { mockResponses } from '../fixtures/responses';
4
+
5
+ describe('Questionnaires Resource', () => {
6
+ let client;
7
+ const apiKey = 'test-api-key';
8
+
9
+ beforeEach(() => {
10
+ client = new Rooguys(apiKey);
11
+ global.fetch.mockClear();
12
+ });
13
+
14
+ describe('get', () => {
15
+ it('should get questionnaire by slug', async () => {
16
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.questionnaireResponse));
17
+
18
+ const result = await client.questionnaires.get('user-persona');
19
+
20
+ expect(global.fetch).toHaveBeenCalledWith(
21
+ expect.stringContaining('/questionnaire/user-persona'),
22
+ expect.any(Object)
23
+ );
24
+ expect(result.slug).toBe('user-persona');
25
+ });
26
+
27
+ it('should throw 404 error when questionnaire not found', async () => {
28
+ global.fetch.mockResolvedValue(mockErrorResponse(404, 'Questionnaire not found'));
29
+
30
+ await expect(client.questionnaires.get('nonexistent-slug')).rejects.toThrow(
31
+ 'Questionnaire not found'
32
+ );
33
+ });
34
+
35
+ it('should handle questionnaire with multiple questions', async () => {
36
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.questionnaireResponse));
37
+
38
+ const result = await client.questionnaires.get('user-persona');
39
+
40
+ expect(result.questions).toBeDefined();
41
+ expect(result.questions.length).toBeGreaterThan(0);
42
+ });
43
+ });
44
+
45
+ describe('getActive', () => {
46
+ it('should get active questionnaire', async () => {
47
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.questionnaireResponse));
48
+
49
+ const result = await client.questionnaires.getActive();
50
+
51
+ expect(global.fetch).toHaveBeenCalledWith(
52
+ expect.stringContaining('/questionnaire/active'),
53
+ expect.any(Object)
54
+ );
55
+ expect(result.is_active).toBe(true);
56
+ });
57
+
58
+ it('should throw 404 error when no active questionnaire', async () => {
59
+ global.fetch.mockResolvedValue(
60
+ mockErrorResponse(404, 'No active questionnaire found for this project')
61
+ );
62
+
63
+ await expect(client.questionnaires.getActive()).rejects.toThrow(
64
+ 'No active questionnaire found'
65
+ );
66
+ });
67
+ });
68
+ });
@@ -0,0 +1,102 @@
1
+ import Rooguys from '../../index';
2
+ import { mockSuccessResponse, mockErrorResponse } from '../utils/mockClient';
3
+ import { mockResponses, mockErrors } from '../fixtures/responses';
4
+
5
+ describe('Users Resource', () => {
6
+ let client;
7
+ const apiKey = 'test-api-key';
8
+
9
+ beforeEach(() => {
10
+ client = new Rooguys(apiKey);
11
+ global.fetch.mockClear();
12
+ });
13
+
14
+ describe('get', () => {
15
+ it('should get a user profile', async () => {
16
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.userProfile));
17
+
18
+ const result = await client.users.get('user_123');
19
+
20
+ expect(global.fetch).toHaveBeenCalledWith(
21
+ expect.stringContaining('/user/user_123'),
22
+ expect.any(Object)
23
+ );
24
+ expect(result).toEqual(mockResponses.userProfile);
25
+ });
26
+
27
+ it('should throw 404 error when user not found', async () => {
28
+ global.fetch.mockResolvedValue(
29
+ mockErrorResponse(404, mockErrors.notFoundError.message)
30
+ );
31
+
32
+ await expect(client.users.get('nonexistent_user')).rejects.toThrow("User 'user123' does not exist in this project");
33
+ });
34
+ });
35
+
36
+ describe('getBulk', () => {
37
+ it('should get multiple user profiles', async () => {
38
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.bulkUsersResponse));
39
+
40
+ const result = await client.users.getBulk(['user1', 'user2']);
41
+
42
+ expect(result).toEqual(mockResponses.bulkUsersResponse);
43
+ expect(result.users).toHaveLength(2);
44
+ });
45
+
46
+ it('should handle empty results', async () => {
47
+ global.fetch.mockResolvedValue(mockSuccessResponse({ users: [] }));
48
+
49
+ const result = await client.users.getBulk(['nonexistent1']);
50
+
51
+ expect(result.users).toEqual([]);
52
+ });
53
+ });
54
+
55
+ describe('getBadges', () => {
56
+ it('should get user badges', async () => {
57
+ global.fetch.mockResolvedValue(
58
+ mockSuccessResponse({ badges: mockResponses.userProfile.badges })
59
+ );
60
+
61
+ const result = await client.users.getBadges('user_123');
62
+
63
+ expect(result.badges).toHaveLength(1);
64
+ });
65
+ });
66
+
67
+ describe('getRank', () => {
68
+ it('should get user rank with default timeframe', async () => {
69
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.userRankResponse));
70
+
71
+ const result = await client.users.getRank('user_123');
72
+
73
+ const callUrl = global.fetch.mock.calls[0][0];
74
+ expect(callUrl).toContain('timeframe=all-time');
75
+ expect(result.rank).toBe(42);
76
+ });
77
+
78
+ it('should get user rank with weekly timeframe', async () => {
79
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.userRankResponse));
80
+
81
+ await client.users.getRank('user_123', 'weekly');
82
+
83
+ const callUrl = global.fetch.mock.calls[0][0];
84
+ expect(callUrl).toContain('timeframe=weekly');
85
+ });
86
+ });
87
+
88
+ describe('submitAnswers', () => {
89
+ it('should submit questionnaire answers', async () => {
90
+ global.fetch.mockResolvedValue(mockSuccessResponse(mockResponses.answerSubmissionResponse));
91
+
92
+ const answers = [
93
+ { question_id: 'q1', answer_option_id: 'a1' },
94
+ { question_id: 'q2', answer_option_id: 'a2' },
95
+ ];
96
+
97
+ const result = await client.users.submitAnswers('user_123', 'questionnaire_id', answers);
98
+
99
+ expect(result.status).toBe('accepted');
100
+ });
101
+ });
102
+ });
@@ -0,0 +1,80 @@
1
+ import fc from 'fast-check';
2
+
3
+ /**
4
+ * Property-based testing generators for SDK inputs
5
+ */
6
+
7
+ export const arbitraries = {
8
+ // User ID: 1-255 characters, alphanumeric with some special chars
9
+ userId: () => fc.string({ minLength: 1, maxLength: 255 }).filter(s => s.trim().length > 0),
10
+
11
+ // Event name: 1-100 characters
12
+ eventName: () => fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
13
+
14
+ // Properties: any JSON-serializable object
15
+ properties: () => fc.dictionary(
16
+ fc.string({ minLength: 1, maxLength: 50 }),
17
+ fc.oneof(
18
+ fc.string(),
19
+ fc.integer(),
20
+ fc.double().filter(n => Number.isFinite(n)), // Exclude Infinity and NaN
21
+ fc.boolean(),
22
+ fc.constant(null)
23
+ )
24
+ ),
25
+
26
+ // Timeframe: one of the valid values
27
+ timeframe: () => fc.constantFrom('all-time', 'weekly', 'monthly'),
28
+
29
+ // Pagination parameters
30
+ pagination: () => fc.record({
31
+ page: fc.integer({ min: 1, max: 1000 }),
32
+ limit: fc.integer({ min: 1, max: 100 }),
33
+ }),
34
+
35
+ // Aha score value: 1-5
36
+ ahaValue: () => fc.integer({ min: 1, max: 5 }),
37
+
38
+ // Invalid Aha score value: outside 1-5 range
39
+ invalidAhaValue: () => fc.oneof(
40
+ fc.integer({ max: 0 }),
41
+ fc.integer({ min: 6 })
42
+ ),
43
+
44
+ // UUID for leaderboard IDs
45
+ uuid: () => fc.uuid(),
46
+
47
+ // Slug for questionnaires
48
+ slug: () => fc.string({ minLength: 1, maxLength: 100 })
49
+ .filter(s => /^[a-z0-9-]+$/.test(s)),
50
+
51
+ // Array of user IDs for bulk operations
52
+ userIds: () => fc.array(
53
+ fc.string({ minLength: 1, maxLength: 255 }),
54
+ { minLength: 1, maxLength: 100 }
55
+ ),
56
+
57
+ // Boolean for active_only filter
58
+ activeOnly: () => fc.boolean(),
59
+
60
+ // Search query
61
+ searchQuery: () => fc.option(fc.string({ maxLength: 100 }), { nil: null }),
62
+
63
+ // Base URL
64
+ baseUrl: () => fc.webUrl(),
65
+
66
+ // Timeout in milliseconds
67
+ timeout: () => fc.integer({ min: 1000, max: 60000 }),
68
+
69
+ // API key
70
+ apiKey: () => fc.string({ minLength: 10, maxLength: 100 }),
71
+
72
+ // Questionnaire answers
73
+ questionnaireAnswers: () => fc.array(
74
+ fc.record({
75
+ question_id: fc.uuid(),
76
+ answer_option_id: fc.uuid(),
77
+ }),
78
+ { minLength: 1, maxLength: 20 }
79
+ ),
80
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Mock fetch for browser SDK testing
3
+ */
4
+ import { jest } from '@jest/globals';
5
+
6
+ export function createMockFetch() {
7
+ return jest.fn();
8
+ }
9
+
10
+ export function mockSuccessResponse(data, status = 200) {
11
+ return Promise.resolve({
12
+ ok: true,
13
+ status,
14
+ json: () => Promise.resolve(data),
15
+ text: () => Promise.resolve(JSON.stringify(data)),
16
+ });
17
+ }
18
+
19
+ export function mockErrorResponse(status, message, details) {
20
+ const errorData = {
21
+ error: message,
22
+ ...(details && { details }),
23
+ };
24
+
25
+ return Promise.resolve({
26
+ ok: false,
27
+ status,
28
+ statusText: message,
29
+ json: () => Promise.resolve(errorData),
30
+ text: () => Promise.resolve(JSON.stringify(errorData)),
31
+ });
32
+ }
33
+
34
+ export function mockNetworkError(message = 'Network error') {
35
+ return new Error(message);
36
+ }
37
+
38
+ export function mockTimeoutError() {
39
+ const error = new Error('The operation was aborted');
40
+ error.name = 'AbortError';
41
+ return error;
42
+ }
package/src/index.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Rooguys Browser SDK
3
+ */
4
+ class Rooguys {
5
+ constructor(apiKey, options = {}) {
6
+ this.apiKey = apiKey;
7
+ this.baseUrl = options.baseUrl || 'https://api.rooguys.com/v1';
8
+ this.timeout = options.timeout || 10000;
9
+ }
10
+
11
+ async _request(endpoint, method = 'GET', data = null, params = {}) {
12
+ const url = new URL(`${this.baseUrl}${endpoint}`);
13
+ Object.keys(params).forEach(key => {
14
+ if (params[key] !== undefined) {
15
+ url.searchParams.append(key, params[key]);
16
+ }
17
+ });
18
+
19
+ const config = {
20
+ method,
21
+ headers: {
22
+ 'x-api-key': this.apiKey,
23
+ 'Content-Type': 'application/json',
24
+ },
25
+ };
26
+
27
+ if (data) {
28
+ config.body = JSON.stringify(data);
29
+ }
30
+
31
+ try {
32
+ const controller = new AbortController();
33
+ const id = setTimeout(() => controller.abort(), this.timeout);
34
+ config.signal = controller.signal;
35
+
36
+ const response = await fetch(url.toString(), config);
37
+ clearTimeout(id);
38
+
39
+ if (!response.ok) {
40
+ const errorData = await response.json().catch(() => ({}));
41
+ throw new Error(errorData.message || `API Error: ${response.statusText}`);
42
+ }
43
+
44
+ return await response.json();
45
+ } catch (error) {
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ get events() {
51
+ return {
52
+ track: (eventName, userId, properties = {}, options = {}) => {
53
+ return this._request('/event', 'POST', {
54
+ event_name: eventName,
55
+ user_id: userId,
56
+ properties,
57
+ }, {
58
+ include_profile: options.includeProfile,
59
+ });
60
+ },
61
+ };
62
+ }
63
+
64
+ get users() {
65
+ return {
66
+ get: (userId) => this._request(`/user/${encodeURIComponent(userId)}`),
67
+
68
+ getBulk: (userIds) => this._request('/users/bulk', 'POST', { user_ids: userIds }),
69
+
70
+ getBadges: (userId) => this._request(`/user/${encodeURIComponent(userId)}/badges`),
71
+
72
+ getRank: (userId, timeframe = 'all-time') =>
73
+ this._request(`/user/${encodeURIComponent(userId)}/rank`, 'GET', null, { timeframe }),
74
+
75
+ submitAnswers: (userId, questionnaireId, answers) =>
76
+ this._request(`/user/${encodeURIComponent(userId)}/answers`, 'POST', {
77
+ questionnaire_id: questionnaireId,
78
+ answers,
79
+ }),
80
+ };
81
+ }
82
+
83
+ get leaderboards() {
84
+ return {
85
+ getGlobal: (timeframe = 'all-time', page = 1, limit = 50) =>
86
+ this._request('/leaderboard', 'GET', null, { timeframe, page, limit }),
87
+
88
+ list: (page = 1, limit = 50, search = null) =>
89
+ this._request('/leaderboards', 'GET', null, { page, limit, search }),
90
+
91
+ getCustom: (leaderboardId, page = 1, limit = 50, search = null) =>
92
+ this._request(`/leaderboard/${encodeURIComponent(leaderboardId)}`, 'GET', null, { page, limit, search }),
93
+
94
+ getUserRank: (leaderboardId, userId) =>
95
+ this._request(`/leaderboard/${encodeURIComponent(leaderboardId)}/user/${encodeURIComponent(userId)}/rank`),
96
+ };
97
+ }
98
+
99
+ get badges() {
100
+ return {
101
+ list: (page = 1, limit = 50, activeOnly = false) =>
102
+ this._request('/badges', 'GET', null, { page, limit, active_only: activeOnly }),
103
+ };
104
+ }
105
+
106
+ get levels() {
107
+ return {
108
+ list: (page = 1, limit = 50) =>
109
+ this._request('/levels', 'GET', null, { page, limit }),
110
+ };
111
+ }
112
+
113
+ get questionnaires() {
114
+ return {
115
+ get: (slug) => this._request(`/questionnaire/${slug}`),
116
+ getActive: () => this._request('/questionnaire/active'),
117
+ };
118
+ }
119
+
120
+ get aha() {
121
+ return {
122
+ declare: (userId, value) => {
123
+ // Validate value is between 1 and 5
124
+ if (!Number.isInteger(value) || value < 1 || value > 5) {
125
+ throw new Error('Aha score value must be an integer between 1 and 5');
126
+ }
127
+ return this._request('/aha/declare', 'POST', {
128
+ user_id: userId,
129
+ value,
130
+ });
131
+ },
132
+
133
+ getUserScore: (userId) => this._request(`/users/${encodeURIComponent(userId)}/aha`),
134
+ };
135
+ }
136
+ }
137
+
138
+ export default Rooguys;