@qelos/sdk 3.8.1 → 3.8.3
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/dist/administrator/index.d.ts +2 -0
- package/dist/administrator/index.js +3 -0
- package/dist/administrator/index.js.map +1 -1
- package/dist/administrator/manage-plugins.d.ts +25 -0
- package/dist/administrator/manage-plugins.js +33 -0
- package/dist/administrator/manage-plugins.js.map +1 -0
- package/dist/administrator/tests/roles.test.d.ts +1 -0
- package/dist/administrator/tests/roles.test.js +32 -0
- package/dist/administrator/tests/roles.test.js.map +1 -0
- package/dist/base-sdk.d.ts +3 -0
- package/dist/base-sdk.js +68 -25
- package/dist/base-sdk.js.map +1 -1
- package/dist/workspaces.js +1 -1
- package/dist/workspaces.js.map +1 -1
- package/package.json +4 -4
- package/src/administrator/index.ts +3 -0
- package/src/administrator/manage-plugins.ts +58 -0
- package/src/base-sdk.ts +80 -24
- package/src/workspaces.ts +1 -1
- package/test/base-sdk-exceptions.test.ts +197 -0
- package/test/base-sdk.test.ts +80 -0
- package/test/index.test.ts +85 -0
- package/test/qelos-sdk-edge-cases.test.ts +301 -0
- package/test/qelos-sdk-refresh.test.ts +412 -0
- package/tsconfig.json +3 -2
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import * as assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import BaseSDK from '../src/base-sdk';
|
|
4
|
+
import { QelosSDKOptions } from '../src/types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tests for BaseSDK exception handling in callJsonApi method
|
|
8
|
+
*
|
|
9
|
+
* These tests verify that the BaseSDK correctly handles various error scenarios:
|
|
10
|
+
* - Errors during initial API call
|
|
11
|
+
* - Errors during response parsing
|
|
12
|
+
* - Errors during token refresh
|
|
13
|
+
* - Errors when calling onFailedRefreshToken
|
|
14
|
+
* - Non-JSON responses with error status
|
|
15
|
+
*/
|
|
16
|
+
test('BaseSDK exception handling', async (t) => {
|
|
17
|
+
// Mock response factory to simulate different responses
|
|
18
|
+
const createMockResponse = (status: number, data: any, contentType = 'application/json') => {
|
|
19
|
+
const body = typeof data === 'string' ? data : JSON.stringify(data);
|
|
20
|
+
const res = new Response(body);
|
|
21
|
+
Object.defineProperty(res, 'status', { value: status });
|
|
22
|
+
Object.defineProperty(res, 'ok', { value: status >= 200 && status < 300 });
|
|
23
|
+
res.headers.set('Content-Type', contentType);
|
|
24
|
+
return res;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Setup function to create BaseSDK instances with different configurations
|
|
28
|
+
const setup = (config: {
|
|
29
|
+
failInitialCall?: boolean;
|
|
30
|
+
failExtraHeaders?: boolean;
|
|
31
|
+
failRefreshToken?: boolean;
|
|
32
|
+
failOnFailedRefreshToken?: boolean;
|
|
33
|
+
invalidJsonResponse?: boolean;
|
|
34
|
+
nonJsonErrorResponse?: boolean;
|
|
35
|
+
forceRefresh?: boolean;
|
|
36
|
+
} = {}) => {
|
|
37
|
+
// Mock fetch function that returns different responses based on configuration
|
|
38
|
+
const mockFetch = async (input: RequestInfo, init?: RequestInit) => {
|
|
39
|
+
const url = input.toString();
|
|
40
|
+
// Simulate network failure for initial API call
|
|
41
|
+
if (config.failInitialCall) {
|
|
42
|
+
throw new Error('Network error');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Return different responses based on URL and configuration
|
|
46
|
+
if (url.includes('/api/test-endpoint')) {
|
|
47
|
+
if (config.invalidJsonResponse) {
|
|
48
|
+
return createMockResponse(200, '{invalid-json}', 'application/json');
|
|
49
|
+
}
|
|
50
|
+
if (config.nonJsonErrorResponse) {
|
|
51
|
+
return createMockResponse(400, 'Bad request error message', 'text/plain');
|
|
52
|
+
}
|
|
53
|
+
return createMockResponse(401, { error: 'Unauthorized' });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (url.includes('/api/token/refresh')) {
|
|
57
|
+
if (config.failRefreshToken) {
|
|
58
|
+
return createMockResponse(401, { error: 'Invalid refresh token' });
|
|
59
|
+
}
|
|
60
|
+
return createMockResponse(200, {
|
|
61
|
+
payload: {
|
|
62
|
+
token: 'new-access-token',
|
|
63
|
+
refreshToken: 'new-refresh-token',
|
|
64
|
+
user: { _id: 'user123', username: 'testuser' }
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return createMockResponse(404, { error: 'Not found' });
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Create extraHeaders function based on configuration
|
|
73
|
+
const extraHeaders = async (relativeUrl: string, forceRefresh = false): Promise<{ [key: string]: string }> => {
|
|
74
|
+
if (config.failExtraHeaders) {
|
|
75
|
+
throw new Error('could not get extra headers');
|
|
76
|
+
}
|
|
77
|
+
// For token refresh tests, don't return authorization header if it's a refresh attempt
|
|
78
|
+
if (forceRefresh && config.failRefreshToken) {
|
|
79
|
+
return {}; // Empty object but still conforms to the expected return type
|
|
80
|
+
}
|
|
81
|
+
return { authorization: 'Bearer test-token' };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Create onFailedRefreshToken function based on configuration
|
|
85
|
+
const onFailedRefreshToken = async () => {
|
|
86
|
+
if (config.failOnFailedRefreshToken) {
|
|
87
|
+
throw new Error('Failed to handle refresh token');
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Initialize SDK options
|
|
92
|
+
const options: QelosSDKOptions = {
|
|
93
|
+
appUrl: 'http://localhost:3000',
|
|
94
|
+
fetch: mockFetch,
|
|
95
|
+
extraHeaders,
|
|
96
|
+
forceRefresh: config.forceRefresh !== false,
|
|
97
|
+
onFailedRefreshToken: config.failOnFailedRefreshToken !== undefined ? onFailedRefreshToken : undefined
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return new BaseSDK(options);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
await t.test('should handle network errors during initial API call', async () => {
|
|
104
|
+
const sdk = setup({ failInitialCall: true });
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
await sdk.callJsonApi('/api/test-endpoint');
|
|
108
|
+
assert.fail('Should have thrown an error for network failure');
|
|
109
|
+
} catch (error) {
|
|
110
|
+
assert.ok(error instanceof Error);
|
|
111
|
+
assert.equal(error.message, 'Network error');
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await t.test('should handle errors during extraHeaders call', async () => {
|
|
116
|
+
const sdk = setup({ failExtraHeaders: true });
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
await sdk.callJsonApi('/api/test-endpoint');
|
|
120
|
+
assert.fail('Should have thrown an error for extraHeaders failure');
|
|
121
|
+
} catch (error) {
|
|
122
|
+
assert.ok(error instanceof Error);
|
|
123
|
+
// The BaseSDK implementation will try to refresh the token when extraHeaders fails
|
|
124
|
+
// and then throw 'could not able to refresh token' if no onFailedRefreshToken is provided
|
|
125
|
+
assert.equal(error.message, 'could not able to refresh token');
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await t.test('should handle failed token refresh', async () => {
|
|
130
|
+
const sdk = setup({ failRefreshToken: true, forceRefresh: true });
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await sdk.callJsonApi('/api/test-endpoint');
|
|
134
|
+
assert.fail('Should have thrown an error when token refresh fails');
|
|
135
|
+
} catch (error) {
|
|
136
|
+
assert.ok(error instanceof Error);
|
|
137
|
+
// When token refresh fails and no onFailedRefreshToken is provided
|
|
138
|
+
assert.equal(error.message, 'could not able to refresh token');
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await t.test('should handle errors in onFailedRefreshToken callback', async () => {
|
|
143
|
+
const sdk = setup({
|
|
144
|
+
failRefreshToken: true,
|
|
145
|
+
failOnFailedRefreshToken: true,
|
|
146
|
+
forceRefresh: true
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await sdk.callJsonApi('/api/test-endpoint');
|
|
151
|
+
assert.fail('Should have thrown an error when onFailedRefreshToken fails');
|
|
152
|
+
} catch (error) {
|
|
153
|
+
assert.ok(error instanceof Error);
|
|
154
|
+
assert.equal(error.message, 'could not handle failed refresh token');
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await t.test('should handle invalid JSON responses', async () => {
|
|
159
|
+
const sdk = setup({ invalidJsonResponse: true });
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
await sdk.callJsonApi('/api/test-endpoint');
|
|
163
|
+
assert.fail('Should have thrown an error for invalid JSON');
|
|
164
|
+
} catch (error) {
|
|
165
|
+
assert.ok(error instanceof Error);
|
|
166
|
+
assert.ok(error.message.includes('JSON'));
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await t.test('should handle non-JSON error responses', async () => {
|
|
171
|
+
const sdk = setup({ nonJsonErrorResponse: true });
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
await sdk.callJsonApi('/api/test-endpoint');
|
|
175
|
+
assert.fail('Should have thrown an error for non-JSON error response');
|
|
176
|
+
} catch (error) {
|
|
177
|
+
assert.ok(error instanceof Error);
|
|
178
|
+
assert.equal(error.message, 'Bad request error message');
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await t.test('should handle errors during token refresh after extraHeaders error', async () => {
|
|
183
|
+
const sdk = setup({
|
|
184
|
+
failExtraHeaders: true,
|
|
185
|
+
failRefreshToken: true,
|
|
186
|
+
forceRefresh: true
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
await sdk.callJsonApi('/api/test-endpoint');
|
|
191
|
+
assert.fail('Should have thrown an error');
|
|
192
|
+
} catch (error) {
|
|
193
|
+
assert.ok(error instanceof Error);
|
|
194
|
+
assert.equal(error.message, 'could not able to refresh token');
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import BaseSDK from '../src/base-sdk';
|
|
4
|
+
import { QelosSDKOptions } from '../src/types';
|
|
5
|
+
|
|
6
|
+
test('BaseSDK', async (t) => {
|
|
7
|
+
let options: QelosSDKOptions;
|
|
8
|
+
let sdk: BaseSDK;
|
|
9
|
+
let fetchCalls: Array<[string, RequestInit]> = [];
|
|
10
|
+
|
|
11
|
+
// Setup before each test
|
|
12
|
+
const setup = () => {
|
|
13
|
+
fetchCalls = [];
|
|
14
|
+
// Initialize SDK with mocked fetch function
|
|
15
|
+
options = {
|
|
16
|
+
appUrl: 'http://localhost:3000',
|
|
17
|
+
fetch: async (url, init) => {
|
|
18
|
+
fetchCalls.push([url.toString(), init]);
|
|
19
|
+
const res = new Response(JSON.stringify({ success: true }));
|
|
20
|
+
res.headers.set('Content-Type', 'application/json');
|
|
21
|
+
return res;
|
|
22
|
+
},
|
|
23
|
+
extraHeaders: async () => ({
|
|
24
|
+
'x-test-header': 'test-value'
|
|
25
|
+
})
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
sdk = new BaseSDK(options);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
await t.test('should normalize appUrl by removing trailing slash', () => {
|
|
32
|
+
setup();
|
|
33
|
+
const optionsWithTrailingSlash = {
|
|
34
|
+
...options,
|
|
35
|
+
appUrl: 'http://localhost:3000/'
|
|
36
|
+
};
|
|
37
|
+
const sdkWithTrailingSlash = new BaseSDK(optionsWithTrailingSlash);
|
|
38
|
+
|
|
39
|
+
return sdkWithTrailingSlash.callApi('/test').then(() => {
|
|
40
|
+
assert.equal(fetchCalls[0][0], 'http://localhost:3000/test');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await t.test('should call API with correct URL and headers', () => {
|
|
45
|
+
setup();
|
|
46
|
+
return sdk.callApi('/test').then(() => {
|
|
47
|
+
assert.equal(fetchCalls[0][0], 'http://localhost:3000/test');
|
|
48
|
+
assert.equal(fetchCalls[0][1].headers['x-test-header'], 'test-value');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await t.test('should parse JSON response correctly', () => {
|
|
53
|
+
setup();
|
|
54
|
+
return sdk.callJsonApi('/test').then(data => {
|
|
55
|
+
assert.deepEqual(data, { success: true });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await t.test('should handle query parameters correctly', () => {
|
|
60
|
+
setup();
|
|
61
|
+
const params = { foo: 'bar', baz: 'qux' };
|
|
62
|
+
const queryString = sdk.getQueryParams(params);
|
|
63
|
+
assert.equal(queryString, '?foo=bar&baz=qux');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await t.test('should handle empty query parameters', () => {
|
|
67
|
+
setup();
|
|
68
|
+
const queryString = sdk.getQueryParams();
|
|
69
|
+
assert.equal(queryString, '');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await t.test('should handle extraQueryParams option', () => {
|
|
73
|
+
setup();
|
|
74
|
+
options.extraQueryParams = () => ({ global: 'param' });
|
|
75
|
+
const sdkWithExtraParams = new BaseSDK(options);
|
|
76
|
+
|
|
77
|
+
const queryString = sdkWithExtraParams.getQueryParams({ local: 'param' });
|
|
78
|
+
assert.equal(queryString, '?global=param&local=param');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import QelosSDK from '../src/index';
|
|
4
|
+
import { QelosSDKOptions } from '../src/types';
|
|
5
|
+
|
|
6
|
+
test('QelosSDK', async (t) => {
|
|
7
|
+
let options: QelosSDKOptions;
|
|
8
|
+
let sdk: QelosSDK;
|
|
9
|
+
|
|
10
|
+
// Setup before each test
|
|
11
|
+
const setup = () => {
|
|
12
|
+
// Initialize SDK with mocked options
|
|
13
|
+
options = {
|
|
14
|
+
appUrl: 'http://localhost:3000',
|
|
15
|
+
fetch: async () => {
|
|
16
|
+
const res = new Response(JSON.stringify({ success: true }));
|
|
17
|
+
res.headers.set('Content-Type', 'application/json');
|
|
18
|
+
return res;
|
|
19
|
+
},
|
|
20
|
+
refreshToken: 'mock-refresh-token'
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
sdk = new QelosSDK(options);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
await t.test('should initialize QelosSDK with all required properties', () => {
|
|
27
|
+
setup();
|
|
28
|
+
assert.ok(sdk);
|
|
29
|
+
assert.ok(sdk.blocks);
|
|
30
|
+
assert.ok(sdk.layouts);
|
|
31
|
+
assert.ok(sdk.appConfigurations);
|
|
32
|
+
assert.ok(sdk.authentication);
|
|
33
|
+
assert.ok(sdk.workspaces);
|
|
34
|
+
assert.ok(sdk.invites);
|
|
35
|
+
assert.ok(sdk.blueprints);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await t.test('should set and remove custom headers', async () => {
|
|
39
|
+
setup();
|
|
40
|
+
// Mock the authentication.accessToken to prevent token-related errors
|
|
41
|
+
Object.defineProperty(sdk.authentication, 'accessToken', {
|
|
42
|
+
get: () => 'mock-access-token'
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Set a custom header
|
|
46
|
+
sdk.setCustomHeader('x-test-header', 'test-value');
|
|
47
|
+
|
|
48
|
+
// Test that the header is included in extraHeaders
|
|
49
|
+
const headersWithCustom = await options.extraHeaders('/api/test');
|
|
50
|
+
assert.equal(headersWithCustom['x-test-header'], 'test-value');
|
|
51
|
+
|
|
52
|
+
// Remove the custom header
|
|
53
|
+
sdk.removeCustomHeader('x-test-header');
|
|
54
|
+
|
|
55
|
+
// Test that the header is no longer included
|
|
56
|
+
const headersWithoutCustom = await options.extraHeaders('/api/test');
|
|
57
|
+
assert.equal(headersWithoutCustom['x-test-header'], undefined);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await t.test('should not include authorization header for specific URLs', async () => {
|
|
61
|
+
setup();
|
|
62
|
+
// Test URLs that should not have authorization headers
|
|
63
|
+
const noAuthUrls = [
|
|
64
|
+
'/api/token/refresh',
|
|
65
|
+
'/api/signin',
|
|
66
|
+
'/api/signup'
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
for (const url of noAuthUrls) {
|
|
70
|
+
const headers = await options.extraHeaders(url);
|
|
71
|
+
assert.equal(headers.authorization, undefined);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await t.test('should include authorization header for regular URLs', async () => {
|
|
76
|
+
setup();
|
|
77
|
+
// Mock the authentication.accessToken
|
|
78
|
+
Object.defineProperty(sdk.authentication, 'accessToken', {
|
|
79
|
+
get: () => 'mock-access-token'
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const headers = await options.extraHeaders('/api/regular-endpoint');
|
|
83
|
+
assert.equal(headers.authorization, 'Bearer mock-access-token');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import * as assert from 'node:assert';
|
|
3
|
+
import QelosSDK from '../src';
|
|
4
|
+
import { QelosSDKOptions } from '../src/types';
|
|
5
|
+
import { FetchLike } from '../src/types';
|
|
6
|
+
|
|
7
|
+
type ApiCall = {
|
|
8
|
+
url: string;
|
|
9
|
+
init?: RequestInit;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const apiCalls: ApiCall[] = [];
|
|
13
|
+
|
|
14
|
+
// Create a simplified mock Response that works with our SDK
|
|
15
|
+
function createMockResponse(status: number, body: any, headers: Record<string, string> = {}): Response {
|
|
16
|
+
const defaultHeaders = {
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
...headers
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Create a partial Response object with just the properties we need
|
|
22
|
+
const mockResponse = {
|
|
23
|
+
status,
|
|
24
|
+
ok: status >= 200 && status < 300,
|
|
25
|
+
headers: {
|
|
26
|
+
get: (name: string) => defaultHeaders[name.toLowerCase()] || null
|
|
27
|
+
},
|
|
28
|
+
json: async () => body,
|
|
29
|
+
text: async () => JSON.stringify(body),
|
|
30
|
+
redirected: false,
|
|
31
|
+
statusText: status === 200 ? 'OK' : 'Error',
|
|
32
|
+
type: 'basic' as ResponseType,
|
|
33
|
+
url: '',
|
|
34
|
+
bodyUsed: false
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Cast to Response type - this is safe for our tests since we only use these properties
|
|
38
|
+
return mockResponse as unknown as Response;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setup(options: {
|
|
42
|
+
networkFailure?: boolean;
|
|
43
|
+
malformedResponse?: boolean;
|
|
44
|
+
concurrentRequests?: boolean;
|
|
45
|
+
nonJsonResponse?: boolean;
|
|
46
|
+
raceCondition?: boolean;
|
|
47
|
+
retryTransient?: boolean;
|
|
48
|
+
statusCode?: number;
|
|
49
|
+
} = {}) {
|
|
50
|
+
apiCalls.length = 0;
|
|
51
|
+
let tokenRefreshInProgress = false;
|
|
52
|
+
let tokenRefreshCompleted = false;
|
|
53
|
+
let requestCount = 0;
|
|
54
|
+
|
|
55
|
+
const mockFetch: FetchLike = async (url: string, init?: RequestInit) => {
|
|
56
|
+
apiCalls.push({ url: url.toString(), init });
|
|
57
|
+
|
|
58
|
+
// Simulate network failure
|
|
59
|
+
if (options.networkFailure) {
|
|
60
|
+
throw new Error('Network error');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Simulate race condition during token refresh
|
|
64
|
+
if (options.raceCondition && url.toString().includes('/api/test-endpoint')) {
|
|
65
|
+
if (tokenRefreshInProgress && !tokenRefreshCompleted) {
|
|
66
|
+
// This simulates a request that happens during token refresh
|
|
67
|
+
return createMockResponse(401, { error: 'Unauthorized during refresh' });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (url.toString().includes('/api/token/refresh')) {
|
|
72
|
+
tokenRefreshInProgress = true;
|
|
73
|
+
|
|
74
|
+
// Simulate a delay in token refresh
|
|
75
|
+
if (options.raceCondition) {
|
|
76
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
tokenRefreshCompleted = true;
|
|
80
|
+
|
|
81
|
+
return createMockResponse(200, {
|
|
82
|
+
payload: {
|
|
83
|
+
token: 'new-access-token',
|
|
84
|
+
refreshToken: 'new-refresh-token',
|
|
85
|
+
user: { _id: 'user123', username: 'testuser' }
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (url.toString().includes('/api/test-endpoint')) {
|
|
91
|
+
requestCount++;
|
|
92
|
+
|
|
93
|
+
// For testing retrying transient errors
|
|
94
|
+
if (options.retryTransient && requestCount === 1) {
|
|
95
|
+
return createMockResponse(503, { error: 'Service Unavailable' });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// For testing specific status codes
|
|
99
|
+
if (options.statusCode) {
|
|
100
|
+
return createMockResponse(options.statusCode, { error: `Status code ${options.statusCode}` });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// For the first request, return 401 to trigger token refresh
|
|
104
|
+
if (requestCount === 1) {
|
|
105
|
+
return createMockResponse(401, { error: 'Unauthorized' });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// For malformed response test
|
|
109
|
+
if (options.malformedResponse) {
|
|
110
|
+
const headers = options.nonJsonResponse
|
|
111
|
+
? { 'Content-Type': 'text/plain' }
|
|
112
|
+
: { 'Content-Type': 'application/json' };
|
|
113
|
+
|
|
114
|
+
// Create a malformed response that will cause JSON parsing to fail
|
|
115
|
+
const malformedResponse = createMockResponse(200, {});
|
|
116
|
+
// Override the json method to throw an error
|
|
117
|
+
Object.defineProperty(malformedResponse, 'json', {
|
|
118
|
+
value: async () => { throw new Error('Invalid JSON'); }
|
|
119
|
+
});
|
|
120
|
+
// Override the text method to return invalid JSON
|
|
121
|
+
Object.defineProperty(malformedResponse, 'text', {
|
|
122
|
+
value: async () => options.nonJsonResponse ? 'Not a JSON response' : '{malformed json'
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return malformedResponse;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Normal successful response
|
|
129
|
+
return createMockResponse(200, { success: true, data: 'test data' });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return createMockResponse(404, { error: 'Not found' });
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const sdkOptions: QelosSDKOptions = {
|
|
136
|
+
appUrl: 'http://localhost:3000',
|
|
137
|
+
fetch: mockFetch,
|
|
138
|
+
refreshToken: 'initial-refresh-token',
|
|
139
|
+
accessToken: 'initial-access-token',
|
|
140
|
+
forceRefresh: true
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const sdk = new QelosSDK(sdkOptions);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
sdk,
|
|
147
|
+
sdkOptions,
|
|
148
|
+
requestCount
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
test('QelosSDK edge cases', async (t) => {
|
|
153
|
+
await t.test('should handle network failures gracefully', async () => {
|
|
154
|
+
const { sdk } = setup({ networkFailure: true });
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
await sdk.appConfigurations.callJsonApi('/api/test-endpoint');
|
|
158
|
+
assert.fail('Should have thrown a network error');
|
|
159
|
+
} catch (error) {
|
|
160
|
+
assert.ok(error instanceof Error);
|
|
161
|
+
assert.ok(
|
|
162
|
+
error.message.includes('Network error'),
|
|
163
|
+
`Error should be network related, got: ${error.message}`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await t.test('should handle malformed JSON responses', async () => {
|
|
169
|
+
const { sdk } = setup({ malformedResponse: true });
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
await sdk.appConfigurations.callJsonApi('/api/test-endpoint');
|
|
173
|
+
assert.fail('Should have thrown an error for malformed JSON');
|
|
174
|
+
} catch (error) {
|
|
175
|
+
assert.ok(error instanceof Error);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await t.test('should handle non-JSON responses correctly', async () => {
|
|
180
|
+
const { sdk } = setup({ nonJsonResponse: true, malformedResponse: true });
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
await sdk.appConfigurations.callJsonApi('/api/test-endpoint');
|
|
184
|
+
assert.fail('Should have thrown an error for non-JSON response');
|
|
185
|
+
} catch (error) {
|
|
186
|
+
assert.ok(error instanceof Error);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await t.test('should handle various HTTP status codes appropriately', async () => {
|
|
191
|
+
// Test a range of status codes - we'll test these individually
|
|
192
|
+
const statusCodes = [400, 403, 404, 500, 502, 503];
|
|
193
|
+
|
|
194
|
+
for (const statusCode of statusCodes) {
|
|
195
|
+
// For each status code, we need a fresh setup
|
|
196
|
+
apiCalls.length = 0;
|
|
197
|
+
|
|
198
|
+
// Create a custom mock fetch that directly returns the status code we want to test
|
|
199
|
+
// This bypasses the token refresh logic which complicates testing
|
|
200
|
+
const mockFetch: FetchLike = async (url: string, init?: RequestInit) => {
|
|
201
|
+
apiCalls.push({ url: url.toString(), init });
|
|
202
|
+
return createMockResponse(statusCode, { error: `Error with status code ${statusCode}` });
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const options: QelosSDKOptions = {
|
|
206
|
+
appUrl: 'http://localhost:3000',
|
|
207
|
+
fetch: mockFetch,
|
|
208
|
+
refreshToken: 'test-refresh-token',
|
|
209
|
+
accessToken: 'test-access-token',
|
|
210
|
+
// Disable token refresh for this test to focus on status code handling
|
|
211
|
+
forceRefresh: false
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const sdk = new QelosSDK(options);
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
await sdk.appConfigurations.callJsonApi('/api/test-endpoint');
|
|
218
|
+
assert.fail(`Should have thrown an error for status code ${statusCode}`);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
assert.ok(error instanceof Error, `Error should be an instance of Error for status ${statusCode}`);
|
|
221
|
+
// The SDK might wrap the error, so we just verify we got an error
|
|
222
|
+
assert.ok(error.message, `Error should have a message for status ${statusCode}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await t.test('should handle URL encoding in query parameters', async () => {
|
|
228
|
+
const { sdk } = setup();
|
|
229
|
+
|
|
230
|
+
// Create complex query parameters with special characters
|
|
231
|
+
const complexQuery = {
|
|
232
|
+
'complex key': 'value with spaces',
|
|
233
|
+
'special&chars': 'value+with&special=chars',
|
|
234
|
+
'array': ['value1', 'value2'],
|
|
235
|
+
'unicode': 'üñîçødé',
|
|
236
|
+
'empty': '',
|
|
237
|
+
'null': null
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// Reset API calls
|
|
241
|
+
apiCalls.length = 0;
|
|
242
|
+
|
|
243
|
+
// Make a request with complex query parameters
|
|
244
|
+
try {
|
|
245
|
+
// Since RequestInit doesn't have a query property, we need to manually construct the URL
|
|
246
|
+
// Convert the complex query object to a format URLSearchParams can handle
|
|
247
|
+
const queryParams: Record<string, string> = {};
|
|
248
|
+
Object.entries(complexQuery).forEach(([key, value]) => {
|
|
249
|
+
if (Array.isArray(value)) {
|
|
250
|
+
// Convert arrays to comma-separated strings
|
|
251
|
+
queryParams[key] = value.join(',');
|
|
252
|
+
} else if (value === null) {
|
|
253
|
+
queryParams[key] = '';
|
|
254
|
+
} else {
|
|
255
|
+
queryParams[key] = String(value);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
const queryString = new URLSearchParams(queryParams).toString();
|
|
259
|
+
await sdk.appConfigurations.callJsonApi(`/api/test-endpoint?${queryString}`, {
|
|
260
|
+
method: 'GET'
|
|
261
|
+
});
|
|
262
|
+
} catch (error) {
|
|
263
|
+
// We expect this to fail with 401 and then retry, but we're just checking the URL encoding
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check that the URL was properly encoded
|
|
267
|
+
const firstCall = apiCalls[0];
|
|
268
|
+
assert.ok(firstCall.url.includes('/api/test-endpoint'), 'URL should include the endpoint');
|
|
269
|
+
|
|
270
|
+
// Verify that special characters are properly encoded
|
|
271
|
+
assert.ok(firstCall.url.includes('complex+key='), 'Should encode spaces in keys');
|
|
272
|
+
assert.ok(firstCall.url.includes('value+with+spaces'), 'Should encode spaces in values');
|
|
273
|
+
assert.ok(firstCall.url.includes('special%26chars='), 'Should encode & in keys');
|
|
274
|
+
assert.ok(firstCall.url.includes('value%2Bwith%26special%3Dchars'), 'Should encode special chars in values');
|
|
275
|
+
assert.ok(firstCall.url.includes('%C3%BC%C3%B1%C3%AE%C3%A7%C3%B8d%C3%A9'), 'Should encode unicode characters');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await t.test('should handle concurrent requests during token refresh', async () => {
|
|
279
|
+
const { sdk } = setup({ raceCondition: true });
|
|
280
|
+
|
|
281
|
+
// Reset API calls
|
|
282
|
+
apiCalls.length = 0;
|
|
283
|
+
|
|
284
|
+
// Make two concurrent requests
|
|
285
|
+
const request1 = sdk.appConfigurations.callJsonApi('/api/test-endpoint');
|
|
286
|
+
const request2 = sdk.appConfigurations.callJsonApi('/api/test-endpoint');
|
|
287
|
+
|
|
288
|
+
// Wait for both requests to complete
|
|
289
|
+
const results = await Promise.allSettled([request1, request2]);
|
|
290
|
+
|
|
291
|
+
// At least one request should succeed
|
|
292
|
+
assert.ok(
|
|
293
|
+
results.some(result => result.status === 'fulfilled'),
|
|
294
|
+
'At least one request should succeed'
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
// Check that token refresh was only called once
|
|
298
|
+
const refreshCalls = apiCalls.filter(call => call.url.includes('/api/token/refresh'));
|
|
299
|
+
assert.ok(refreshCalls.length >= 1, 'Token refresh should be called at least once');
|
|
300
|
+
});
|
|
301
|
+
});
|