@youversion/platform-core 0.4.1
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/.env.example +7 -0
- package/.env.local +10 -0
- package/.turbo/turbo-build.log +18 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +201 -0
- package/README.md +369 -0
- package/dist/index.cjs +1330 -0
- package/dist/index.d.cts +737 -0
- package/dist/index.d.ts +737 -0
- package/dist/index.js +1286 -0
- package/package.json +46 -0
- package/src/AuthenticationStrategy.ts +78 -0
- package/src/SignInWithYouVersionResult.ts +53 -0
- package/src/StorageStrategy.ts +81 -0
- package/src/URLBuilder.ts +71 -0
- package/src/Users.ts +137 -0
- package/src/WebAuthenticationStrategy.ts +127 -0
- package/src/YouVersionAPI.ts +27 -0
- package/src/YouVersionPlatformConfiguration.ts +80 -0
- package/src/YouVersionUserInfo.ts +49 -0
- package/src/__tests__/StorageStrategy.test.ts +404 -0
- package/src/__tests__/URLBuilder.test.ts +289 -0
- package/src/__tests__/YouVersionPlatformConfiguration.test.ts +150 -0
- package/src/__tests__/authentication.test.ts +174 -0
- package/src/__tests__/bible.test.ts +356 -0
- package/src/__tests__/client.test.ts +109 -0
- package/src/__tests__/handlers.ts +41 -0
- package/src/__tests__/highlights.test.ts +485 -0
- package/src/__tests__/languages.test.ts +139 -0
- package/src/__tests__/setup.ts +17 -0
- package/src/authentication.ts +27 -0
- package/src/bible.ts +272 -0
- package/src/client.ts +162 -0
- package/src/highlight.ts +16 -0
- package/src/highlights.ts +173 -0
- package/src/index.ts +20 -0
- package/src/languages.ts +80 -0
- package/src/schemas/bible-index.ts +48 -0
- package/src/schemas/book.ts +34 -0
- package/src/schemas/chapter.ts +24 -0
- package/src/schemas/collection.ts +28 -0
- package/src/schemas/highlight.ts +23 -0
- package/src/schemas/index.ts +11 -0
- package/src/schemas/language.ts +38 -0
- package/src/schemas/passage.ts +14 -0
- package/src/schemas/user.ts +10 -0
- package/src/schemas/verse.ts +17 -0
- package/src/schemas/version.ts +31 -0
- package/src/schemas/votd.ts +10 -0
- package/src/types/api-config.ts +9 -0
- package/src/types/auth.ts +15 -0
- package/src/types/book.ts +116 -0
- package/src/types/chapter.ts +5 -0
- package/src/types/highlight.ts +9 -0
- package/src/types/index.ts +22 -0
- package/src/utils/constants.ts +219 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +12 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { URLBuilder } from '../URLBuilder';
|
|
3
|
+
import { YouVersionPlatformConfiguration } from '../YouVersionPlatformConfiguration';
|
|
4
|
+
import type { SignInWithYouVersionPermissionValues } from '../types';
|
|
5
|
+
|
|
6
|
+
describe('URLBuilder - Input Validation', () => {
|
|
7
|
+
let originalApiHost: string;
|
|
8
|
+
let originalInstallationId: string | undefined;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
// Store original config
|
|
12
|
+
originalApiHost = YouVersionPlatformConfiguration.apiHost;
|
|
13
|
+
originalInstallationId = YouVersionPlatformConfiguration.installationId;
|
|
14
|
+
|
|
15
|
+
// Set test config
|
|
16
|
+
YouVersionPlatformConfiguration.apiHost = 'api-dev.youversion.com';
|
|
17
|
+
YouVersionPlatformConfiguration.installationId = 'test-installation-id';
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
// Restore original config
|
|
22
|
+
YouVersionPlatformConfiguration.apiHost = originalApiHost;
|
|
23
|
+
YouVersionPlatformConfiguration.installationId = originalInstallationId;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('authURL - appId validation', () => {
|
|
27
|
+
it('should throw error for empty string appId', () => {
|
|
28
|
+
expect(() => {
|
|
29
|
+
URLBuilder.authURL('');
|
|
30
|
+
}).toThrow('appId must be a non-empty string');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should throw error for whitespace-only appId', () => {
|
|
34
|
+
expect(() => {
|
|
35
|
+
URLBuilder.authURL(' ');
|
|
36
|
+
}).toThrow('appId must be a non-empty string');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should throw error for tab/newline-only appId', () => {
|
|
40
|
+
expect(() => {
|
|
41
|
+
URLBuilder.authURL('\t\n ');
|
|
42
|
+
}).toThrow('appId must be a non-empty string');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should throw descriptive error message', () => {
|
|
46
|
+
try {
|
|
47
|
+
URLBuilder.authURL('');
|
|
48
|
+
// Should not reach here
|
|
49
|
+
expect.fail('Should have thrown an error');
|
|
50
|
+
} catch (error) {
|
|
51
|
+
expect(error).toBeInstanceOf(Error);
|
|
52
|
+
expect((error as Error).message).toContain('appId');
|
|
53
|
+
expect((error as Error).message).toContain('non-empty string');
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should accept valid non-empty appId', () => {
|
|
58
|
+
const url = URLBuilder.authURL('valid-app-id');
|
|
59
|
+
|
|
60
|
+
expect(url).toBeInstanceOf(URL);
|
|
61
|
+
expect(url.hostname).toBe('api-dev.youversion.com');
|
|
62
|
+
expect(url.pathname).toBe('/auth/login');
|
|
63
|
+
expect(url.searchParams.get('app_id')).toBe('valid-app-id');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should trim and accept appId with surrounding whitespace', () => {
|
|
67
|
+
// Note: The validation checks trim().length, so this should pass
|
|
68
|
+
const url = URLBuilder.authURL(' valid-app-id ');
|
|
69
|
+
|
|
70
|
+
expect(url).toBeInstanceOf(URL);
|
|
71
|
+
// The actual value passed has whitespace preserved in the URL
|
|
72
|
+
expect(url.searchParams.get('app_id')).toBe(' valid-app-id ');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should accept appId with special characters', () => {
|
|
76
|
+
const specialAppId = 'app-id_123.test';
|
|
77
|
+
const url = URLBuilder.authURL(specialAppId);
|
|
78
|
+
|
|
79
|
+
expect(url.searchParams.get('app_id')).toBe(specialAppId);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('authURL - URL construction', () => {
|
|
84
|
+
it('should construct correct base URL and pathname', () => {
|
|
85
|
+
const url = URLBuilder.authURL('test-app');
|
|
86
|
+
|
|
87
|
+
expect(url.protocol).toBe('https:');
|
|
88
|
+
expect(url.hostname).toBe('api-dev.youversion.com');
|
|
89
|
+
expect(url.pathname).toBe('/auth/login');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should include app_id in query parameters', () => {
|
|
93
|
+
const url = URLBuilder.authURL('my-app-id');
|
|
94
|
+
|
|
95
|
+
expect(url.searchParams.get('app_id')).toBe('my-app-id');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should include default language parameter', () => {
|
|
99
|
+
const url = URLBuilder.authURL('test-app');
|
|
100
|
+
|
|
101
|
+
expect(url.searchParams.get('language')).toBe('en');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should include installation ID when configured', () => {
|
|
105
|
+
YouVersionPlatformConfiguration.installationId = 'test-installation-123';
|
|
106
|
+
const url = URLBuilder.authURL('test-app');
|
|
107
|
+
|
|
108
|
+
expect(url.searchParams.get('x-yvp-installation-id')).toBe('test-installation-123');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should not include installation ID when not configured', () => {
|
|
112
|
+
YouVersionPlatformConfiguration.installationId = undefined;
|
|
113
|
+
const url = URLBuilder.authURL('test-app');
|
|
114
|
+
|
|
115
|
+
expect(url.searchParams.get('x-yvp-installation-id')).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should include required permissions when provided', () => {
|
|
119
|
+
const permissions = new Set<SignInWithYouVersionPermissionValues>([
|
|
120
|
+
'plans.read',
|
|
121
|
+
'user.read',
|
|
122
|
+
]);
|
|
123
|
+
const url = URLBuilder.authURL('test-app', permissions);
|
|
124
|
+
|
|
125
|
+
const requiredPerms = url.searchParams.get('required_perms');
|
|
126
|
+
expect(requiredPerms).toBeTruthy();
|
|
127
|
+
expect(requiredPerms?.split(',')).toContain('plans.read');
|
|
128
|
+
expect(requiredPerms?.split(',')).toContain('user.read');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should include optional permissions when provided', () => {
|
|
132
|
+
const optionalPermissions = new Set<SignInWithYouVersionPermissionValues>(['moments.read']);
|
|
133
|
+
const url = URLBuilder.authURL('test-app', new Set(), optionalPermissions);
|
|
134
|
+
|
|
135
|
+
const optPerms = url.searchParams.get('opt_perms');
|
|
136
|
+
expect(optPerms).toBe('moments.read');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should include both required and optional permissions', () => {
|
|
140
|
+
const required = new Set<SignInWithYouVersionPermissionValues>(['user.read']);
|
|
141
|
+
const optional = new Set<SignInWithYouVersionPermissionValues>(['plans.read']);
|
|
142
|
+
const url = URLBuilder.authURL('test-app', required, optional);
|
|
143
|
+
|
|
144
|
+
expect(url.searchParams.get('required_perms')).toBe('user.read');
|
|
145
|
+
expect(url.searchParams.get('opt_perms')).toBe('plans.read');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should handle empty permission sets', () => {
|
|
149
|
+
const url = URLBuilder.authURL('test-app', new Set(), new Set());
|
|
150
|
+
|
|
151
|
+
expect(url.searchParams.get('required_perms')).toBeNull();
|
|
152
|
+
expect(url.searchParams.get('opt_perms')).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('userURL - accessToken validation', () => {
|
|
157
|
+
it('should throw error for empty string accessToken', () => {
|
|
158
|
+
expect(() => {
|
|
159
|
+
URLBuilder.userURL('');
|
|
160
|
+
}).toThrow('accessToken must be a non-empty string');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should throw error for whitespace-only accessToken', () => {
|
|
164
|
+
expect(() => {
|
|
165
|
+
URLBuilder.userURL(' ');
|
|
166
|
+
}).toThrow('accessToken must be a non-empty string');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should throw error for tab/newline-only accessToken', () => {
|
|
170
|
+
expect(() => {
|
|
171
|
+
URLBuilder.userURL('\t\n ');
|
|
172
|
+
}).toThrow('accessToken must be a non-empty string');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should throw descriptive error message', () => {
|
|
176
|
+
try {
|
|
177
|
+
URLBuilder.userURL('');
|
|
178
|
+
expect.fail('Should have thrown an error');
|
|
179
|
+
} catch (error) {
|
|
180
|
+
expect(error).toBeInstanceOf(Error);
|
|
181
|
+
expect((error as Error).message).toContain('accessToken');
|
|
182
|
+
expect((error as Error).message).toContain('non-empty string');
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should accept valid non-empty accessToken', () => {
|
|
187
|
+
const url = URLBuilder.userURL('valid-access-token-123');
|
|
188
|
+
|
|
189
|
+
expect(url).toBeInstanceOf(URL);
|
|
190
|
+
expect(url.hostname).toBe('api-dev.youversion.com');
|
|
191
|
+
expect(url.pathname).toBe('/auth/me');
|
|
192
|
+
expect(url.searchParams.get('lat')).toBe('valid-access-token-123');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should accept accessToken with special characters', () => {
|
|
196
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.signature';
|
|
197
|
+
const url = URLBuilder.userURL(token);
|
|
198
|
+
|
|
199
|
+
expect(url.searchParams.get('lat')).toBe(token);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('userURL - URL construction', () => {
|
|
204
|
+
it('should construct correct base URL and pathname', () => {
|
|
205
|
+
const url = URLBuilder.userURL('test-token');
|
|
206
|
+
|
|
207
|
+
expect(url.protocol).toBe('https:');
|
|
208
|
+
expect(url.hostname).toBe('api-dev.youversion.com');
|
|
209
|
+
expect(url.pathname).toBe('/auth/me');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should include access token in lat query parameter', () => {
|
|
213
|
+
const token = 'my-access-token-abc123';
|
|
214
|
+
const url = URLBuilder.userURL(token);
|
|
215
|
+
|
|
216
|
+
expect(url.searchParams.get('lat')).toBe(token);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should properly encode special characters in token', () => {
|
|
220
|
+
const tokenWithSpecialChars = 'token+with/special=chars';
|
|
221
|
+
const url = URLBuilder.userURL(tokenWithSpecialChars);
|
|
222
|
+
|
|
223
|
+
// URLSearchParams automatically encodes special characters
|
|
224
|
+
expect(url.searchParams.get('lat')).toBe(tokenWithSpecialChars);
|
|
225
|
+
expect(url.toString()).toContain('token%2Bwith%2Fspecial%3Dchars');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('Error handling', () => {
|
|
230
|
+
it('should throw errors instead of returning null for invalid appId', () => {
|
|
231
|
+
// Verify that the method throws, not returns null
|
|
232
|
+
let threwError = false;
|
|
233
|
+
let returnValue: any;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
returnValue = URLBuilder.authURL('');
|
|
237
|
+
} catch {
|
|
238
|
+
threwError = true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
expect(threwError).toBe(true);
|
|
242
|
+
expect(returnValue).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should throw errors instead of returning null for invalid accessToken', () => {
|
|
246
|
+
let threwError = false;
|
|
247
|
+
let returnValue: any;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
returnValue = URLBuilder.userURL('');
|
|
251
|
+
} catch {
|
|
252
|
+
threwError = true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
expect(threwError).toBe(true);
|
|
256
|
+
expect(returnValue).toBeUndefined();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should wrap URL construction errors with descriptive message for authURL', () => {
|
|
260
|
+
// This is hard to trigger, but we can at least verify the pattern exists
|
|
261
|
+
// by checking that valid inputs don't trigger the catch block
|
|
262
|
+
expect(() => {
|
|
263
|
+
URLBuilder.authURL('valid-app-id');
|
|
264
|
+
}).not.toThrow(/Failed to construct auth URL/);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should wrap URL construction errors with descriptive message for userURL', () => {
|
|
268
|
+
expect(() => {
|
|
269
|
+
URLBuilder.userURL('valid-token');
|
|
270
|
+
}).not.toThrow(/Failed to construct user URL/);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('Return type validation', () => {
|
|
275
|
+
it('authURL should return URL object (not null)', () => {
|
|
276
|
+
const result = URLBuilder.authURL('test-app');
|
|
277
|
+
|
|
278
|
+
expect(result).toBeInstanceOf(URL);
|
|
279
|
+
expect(result).not.toBeNull();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('userURL should return URL object (not null)', () => {
|
|
283
|
+
const result = URLBuilder.userURL('test-token');
|
|
284
|
+
|
|
285
|
+
expect(result).toBeInstanceOf(URL);
|
|
286
|
+
expect(result).not.toBeNull();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
// Mock globals before importing the module
|
|
7
|
+
const mockUUID = '550e8400-e29b-41d4-a716-446655440000';
|
|
8
|
+
const mockRandomUUID = vi.fn(() => mockUUID);
|
|
9
|
+
const mockGetItem = vi.fn();
|
|
10
|
+
const mockSetItem = vi.fn();
|
|
11
|
+
|
|
12
|
+
vi.stubGlobal('crypto', { randomUUID: mockRandomUUID });
|
|
13
|
+
vi.stubGlobal('localStorage', { getItem: mockGetItem, setItem: mockSetItem });
|
|
14
|
+
|
|
15
|
+
import { YouVersionPlatformConfiguration } from '../YouVersionPlatformConfiguration';
|
|
16
|
+
|
|
17
|
+
describe('YouVersionPlatformConfiguration', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
// Reset all static properties
|
|
20
|
+
YouVersionPlatformConfiguration.appId = null;
|
|
21
|
+
YouVersionPlatformConfiguration.installationId = null;
|
|
22
|
+
YouVersionPlatformConfiguration.setAccessToken(null);
|
|
23
|
+
YouVersionPlatformConfiguration.apiHost = 'api-dev.youversion.com';
|
|
24
|
+
YouVersionPlatformConfiguration.isPreviewMode = false;
|
|
25
|
+
YouVersionPlatformConfiguration.previewUserInfo = null;
|
|
26
|
+
|
|
27
|
+
// Clear call history but keep implementation
|
|
28
|
+
mockRandomUUID.mockClear();
|
|
29
|
+
mockGetItem.mockClear();
|
|
30
|
+
mockSetItem.mockClear();
|
|
31
|
+
|
|
32
|
+
// Setup localStorage to return null by default (empty)
|
|
33
|
+
mockGetItem.mockReturnValue(null);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('appId', () => {
|
|
41
|
+
it('should get and set appId', () => {
|
|
42
|
+
expect(YouVersionPlatformConfiguration.appId).toBeNull();
|
|
43
|
+
|
|
44
|
+
YouVersionPlatformConfiguration.appId = 'test-app-id';
|
|
45
|
+
expect(YouVersionPlatformConfiguration.appId).toBe('test-app-id');
|
|
46
|
+
|
|
47
|
+
YouVersionPlatformConfiguration.appId = null;
|
|
48
|
+
expect(YouVersionPlatformConfiguration.appId).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('installationId', () => {
|
|
53
|
+
it('should get and set installationId when provided', () => {
|
|
54
|
+
const testId = 'test-installation-id';
|
|
55
|
+
YouVersionPlatformConfiguration.installationId = testId;
|
|
56
|
+
|
|
57
|
+
expect(YouVersionPlatformConfiguration.installationId).toBe(testId);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should generate and store UUID when installationId is null', () => {
|
|
61
|
+
YouVersionPlatformConfiguration.installationId = null;
|
|
62
|
+
|
|
63
|
+
expect(mockRandomUUID).toHaveBeenCalled();
|
|
64
|
+
expect(YouVersionPlatformConfiguration.installationId).toBe(mockUUID);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should generate and store UUID when installationId is empty string', () => {
|
|
68
|
+
YouVersionPlatformConfiguration.installationId = '';
|
|
69
|
+
|
|
70
|
+
expect(mockRandomUUID).toHaveBeenCalled();
|
|
71
|
+
expect(YouVersionPlatformConfiguration.installationId).toBe(mockUUID);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should use existing UUID from localStorage when installationId is null', () => {
|
|
75
|
+
const existingUUID = 'existing-uuid-from-storage';
|
|
76
|
+
mockGetItem.mockReturnValue(existingUUID);
|
|
77
|
+
|
|
78
|
+
YouVersionPlatformConfiguration.installationId = null;
|
|
79
|
+
|
|
80
|
+
expect(mockRandomUUID).not.toHaveBeenCalled();
|
|
81
|
+
expect(YouVersionPlatformConfiguration.installationId).toBe(existingUUID);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should generate new UUID when localStorage is empty', () => {
|
|
85
|
+
YouVersionPlatformConfiguration.installationId = null;
|
|
86
|
+
|
|
87
|
+
expect(mockRandomUUID).toHaveBeenCalled();
|
|
88
|
+
expect(YouVersionPlatformConfiguration.installationId).toBe(mockUUID);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('accessToken', () => {
|
|
93
|
+
it('should set and get access token', () => {
|
|
94
|
+
expect(YouVersionPlatformConfiguration.accessToken).toBeNull();
|
|
95
|
+
|
|
96
|
+
const token = 'test-access-token';
|
|
97
|
+
YouVersionPlatformConfiguration.setAccessToken(token);
|
|
98
|
+
expect(YouVersionPlatformConfiguration.accessToken).toBe(token);
|
|
99
|
+
|
|
100
|
+
YouVersionPlatformConfiguration.setAccessToken(null);
|
|
101
|
+
expect(YouVersionPlatformConfiguration.accessToken).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('apiHost', () => {
|
|
106
|
+
it('should get and set apiHost', () => {
|
|
107
|
+
expect(YouVersionPlatformConfiguration.apiHost).toBe('api-dev.youversion.com');
|
|
108
|
+
|
|
109
|
+
YouVersionPlatformConfiguration.apiHost = 'api.youversion.com';
|
|
110
|
+
expect(YouVersionPlatformConfiguration.apiHost).toBe('api.youversion.com');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('isPreviewMode', () => {
|
|
115
|
+
it('should get and set preview mode', () => {
|
|
116
|
+
expect(YouVersionPlatformConfiguration.isPreviewMode).toBe(false);
|
|
117
|
+
|
|
118
|
+
YouVersionPlatformConfiguration.isPreviewMode = true;
|
|
119
|
+
expect(YouVersionPlatformConfiguration.isPreviewMode).toBe(true);
|
|
120
|
+
|
|
121
|
+
YouVersionPlatformConfiguration.isPreviewMode = false;
|
|
122
|
+
expect(YouVersionPlatformConfiguration.isPreviewMode).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('UUID generation edge cases', () => {
|
|
127
|
+
it('should generate valid UUID format', () => {
|
|
128
|
+
YouVersionPlatformConfiguration.installationId = null;
|
|
129
|
+
|
|
130
|
+
const generatedId = YouVersionPlatformConfiguration.installationId;
|
|
131
|
+
expect(generatedId).toBe(mockUUID);
|
|
132
|
+
expect(generatedId).toMatch(
|
|
133
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('multiple calls consistency', () => {
|
|
139
|
+
it('should return same UUID on multiple null assignments', () => {
|
|
140
|
+
YouVersionPlatformConfiguration.installationId = null;
|
|
141
|
+
const firstId = YouVersionPlatformConfiguration.installationId;
|
|
142
|
+
|
|
143
|
+
YouVersionPlatformConfiguration.installationId = null;
|
|
144
|
+
const secondId = YouVersionPlatformConfiguration.installationId;
|
|
145
|
+
|
|
146
|
+
expect(firstId).toBe(secondId);
|
|
147
|
+
expect(firstId).toBe(mockUUID);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
5
|
+
import { ApiClient } from '../client';
|
|
6
|
+
import { AuthClient } from '../authentication';
|
|
7
|
+
import { WebAuthenticationStrategy } from '../WebAuthenticationStrategy';
|
|
8
|
+
|
|
9
|
+
describe('AuthClient', () => {
|
|
10
|
+
let apiClient: ApiClient;
|
|
11
|
+
let authClient: AuthClient;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
global.fetch = vi.fn();
|
|
15
|
+
apiClient = new ApiClient({
|
|
16
|
+
baseUrl: 'https://api-dev.youversion.com',
|
|
17
|
+
appId: 'test-app-id',
|
|
18
|
+
version: 'v1',
|
|
19
|
+
});
|
|
20
|
+
authClient = new AuthClient(apiClient);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('getUser', () => {
|
|
28
|
+
it('should fetch user information', async () => {
|
|
29
|
+
const mockUser = {
|
|
30
|
+
id: '123',
|
|
31
|
+
first_name: 'John',
|
|
32
|
+
last_name: 'Doe',
|
|
33
|
+
avatar_url: 'https://example.com/avatar.jpg',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
vi.mocked(global.fetch).mockResolvedValueOnce({
|
|
37
|
+
ok: true,
|
|
38
|
+
json: () => Promise.resolve(mockUser),
|
|
39
|
+
headers: {
|
|
40
|
+
get: vi.fn((name: string) => (name === 'content-type' ? 'application/json' : null)),
|
|
41
|
+
},
|
|
42
|
+
} as Response);
|
|
43
|
+
|
|
44
|
+
const user = await authClient.getUser('test-token');
|
|
45
|
+
|
|
46
|
+
expect(user).toEqual(mockUser);
|
|
47
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
48
|
+
expect.stringContaining('/auth/me'),
|
|
49
|
+
expect.any(Object),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('WebAuthenticationStrategy', () => {
|
|
56
|
+
let strategy: WebAuthenticationStrategy;
|
|
57
|
+
const oldWindowLocation = window.location;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
61
|
+
delete (window as any).location;
|
|
62
|
+
window.location = {
|
|
63
|
+
...oldWindowLocation,
|
|
64
|
+
href: 'http://localhost:3000',
|
|
65
|
+
origin: 'http://localhost:3000',
|
|
66
|
+
pathname: '/',
|
|
67
|
+
assign: vi.fn(),
|
|
68
|
+
replace: vi.fn(),
|
|
69
|
+
} as Location;
|
|
70
|
+
|
|
71
|
+
const sessionStorageMock = (() => {
|
|
72
|
+
let store: Record<string, string> = {};
|
|
73
|
+
return {
|
|
74
|
+
getItem: vi.fn((key: string) => store[key] || null),
|
|
75
|
+
setItem: vi.fn((key: string, value: string) => {
|
|
76
|
+
store[key] = value;
|
|
77
|
+
}),
|
|
78
|
+
removeItem: vi.fn((key: string) => {
|
|
79
|
+
delete store[key];
|
|
80
|
+
}),
|
|
81
|
+
clear: vi.fn(() => {
|
|
82
|
+
store = {};
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
})();
|
|
86
|
+
|
|
87
|
+
Object.defineProperty(window, 'sessionStorage', {
|
|
88
|
+
value: sessionStorageMock,
|
|
89
|
+
writable: true,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
window.history.replaceState = vi.fn();
|
|
93
|
+
|
|
94
|
+
strategy = new WebAuthenticationStrategy({
|
|
95
|
+
callbackPath: '/auth/callback',
|
|
96
|
+
timeout: 5000,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
window.location = oldWindowLocation;
|
|
102
|
+
WebAuthenticationStrategy.cleanup();
|
|
103
|
+
vi.clearAllMocks();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('authenticate', () => {
|
|
107
|
+
it('should redirect to auth URL', () => {
|
|
108
|
+
const authUrl = new URL('https://auth.youversion.com/authorize');
|
|
109
|
+
|
|
110
|
+
void strategy.authenticate(authUrl);
|
|
111
|
+
|
|
112
|
+
expect(window.location.href).toContain('auth.youversion.com');
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
114
|
+
expect(sessionStorage.setItem).toHaveBeenCalledWith(
|
|
115
|
+
'youversion-auth-return',
|
|
116
|
+
'http://localhost:3000',
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('handleCallback', () => {
|
|
122
|
+
it('should handle callback and return true', () => {
|
|
123
|
+
window.location.pathname = '/auth/callback';
|
|
124
|
+
window.location.href = 'http://localhost:3000/auth/callback?status=success';
|
|
125
|
+
// Add searchParams to mock location object for testing
|
|
126
|
+
Object.defineProperty(window.location, 'searchParams', {
|
|
127
|
+
value: new URLSearchParams('status=success'),
|
|
128
|
+
writable: true,
|
|
129
|
+
configurable: true,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const result = WebAuthenticationStrategy.handleCallback('/auth/callback');
|
|
133
|
+
|
|
134
|
+
expect(result).toBe(true);
|
|
135
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
136
|
+
expect(sessionStorage.setItem).toHaveBeenCalledWith(
|
|
137
|
+
'youversion-auth-callback',
|
|
138
|
+
expect.stringContaining('status=success'),
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should return false for non-callback URLs', () => {
|
|
143
|
+
window.location.pathname = '/';
|
|
144
|
+
Object.defineProperty(window.location, 'searchParams', {
|
|
145
|
+
value: new URLSearchParams(''),
|
|
146
|
+
writable: true,
|
|
147
|
+
configurable: true,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const result = WebAuthenticationStrategy.handleCallback('/auth/callback');
|
|
151
|
+
|
|
152
|
+
expect(result).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('getStoredCallback', () => {
|
|
157
|
+
it('should retrieve and remove stored callback', () => {
|
|
158
|
+
const callbackUrl = 'http://localhost:3000/auth/callback?status=success';
|
|
159
|
+
sessionStorage.setItem('youversion-auth-callback', callbackUrl);
|
|
160
|
+
|
|
161
|
+
const result = WebAuthenticationStrategy.getStoredCallback();
|
|
162
|
+
|
|
163
|
+
expect(result?.toString()).toBe(callbackUrl);
|
|
164
|
+
// eslint-disable-next-line @typescript-eslint/unbound-method
|
|
165
|
+
expect(sessionStorage.removeItem).toHaveBeenCalledWith('youversion-auth-callback');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should return null if no stored callback', () => {
|
|
169
|
+
const result = WebAuthenticationStrategy.getStoredCallback();
|
|
170
|
+
|
|
171
|
+
expect(result).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|