@youversion/platform-core 0.5.8 → 0.7.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,737 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { YouVersionAPIUsers } from '../Users';
3
+ import { YouVersionPlatformConfiguration } from '../YouVersionPlatformConfiguration';
4
+ import { SignInWithYouVersionPermission } from '../SignInWithYouVersionResult';
5
+ import { YouVersionUserInfo } from '../YouVersionUserInfo';
6
+ import type { SignInWithYouVersionPermissionValues } from '../types/auth';
7
+ import { setupBrowserMocks, cleanupBrowserMocks } from './mocks/browser';
8
+
9
+ const mockFetch = vi.fn();
10
+
11
+ describe('YouVersionAPIUsers', () => {
12
+ let mocks: ReturnType<typeof setupBrowserMocks>;
13
+
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+
17
+ // Setup global mocks
18
+ mocks = setupBrowserMocks();
19
+ vi.stubGlobal('fetch', mockFetch);
20
+
21
+ // Override randomUUID for this test suite
22
+ mocks.crypto.randomUUID = vi.fn(() => 'test-installation-id');
23
+
24
+ // Reset location
25
+ mocks.window.location.href = '';
26
+ mocks.window.location.search = '';
27
+
28
+ // Setup YouVersionPlatformConfiguration
29
+ YouVersionPlatformConfiguration.appKey = 'test-app-key';
30
+ YouVersionPlatformConfiguration.apiHost = 'api.youversion.com';
31
+ });
32
+
33
+ afterEach(() => {
34
+ cleanupBrowserMocks();
35
+ vi.unstubAllGlobals();
36
+ YouVersionPlatformConfiguration.appKey = null;
37
+ });
38
+
39
+ describe('signIn', () => {
40
+ it('should throw error when appKey is not set', async () => {
41
+ YouVersionPlatformConfiguration.appKey = null;
42
+
43
+ const permissions = new Set<SignInWithYouVersionPermissionValues>([
44
+ SignInWithYouVersionPermission.bibles,
45
+ ]);
46
+
47
+ await expect(
48
+ YouVersionAPIUsers.signIn(permissions, 'https://example.com/callback'),
49
+ ).rejects.toThrow('YouVersionPlatformConfiguration.appKey must be set before calling signIn');
50
+ });
51
+
52
+ it('should create authorization request and redirect on successful signIn', async () => {
53
+ vi.spyOn(crypto, 'getRandomValues').mockImplementation((array: ArrayBufferView) => {
54
+ if (array instanceof Uint8Array) {
55
+ for (let i = 0; i < array.length; i++) {
56
+ array[i] = i;
57
+ }
58
+ }
59
+ return array;
60
+ });
61
+
62
+ vi.spyOn(crypto.subtle, 'digest').mockResolvedValue(new Uint8Array(32).buffer);
63
+ mocks.btoa.mockReturnValue('mockBase64Value');
64
+
65
+ const permissions = new Set<SignInWithYouVersionPermissionValues>([
66
+ SignInWithYouVersionPermission.bibles,
67
+ SignInWithYouVersionPermission.highlights,
68
+ ]);
69
+ const redirectURL = 'https://example.com/callback';
70
+
71
+ await YouVersionAPIUsers.signIn(permissions, redirectURL);
72
+
73
+ // Verify localStorage items stored
74
+ expect(mocks.localStorage.setItem).toHaveBeenCalledWith(
75
+ 'youversion-auth-code-verifier',
76
+ expect.any(String),
77
+ );
78
+ expect(mocks.localStorage.setItem).toHaveBeenCalledWith(
79
+ 'youversion-auth-redirect-uri',
80
+ redirectURL,
81
+ );
82
+ expect(mocks.localStorage.setItem).toHaveBeenCalledWith(
83
+ 'youversion-auth-state',
84
+ expect.any(String),
85
+ );
86
+
87
+ // Verify redirect occurred
88
+ expect(mocks.window.location.href).toContain('https://api.youversion.com/auth/authorize');
89
+
90
+ vi.restoreAllMocks();
91
+ });
92
+ });
93
+
94
+ describe('handleAuthCallback', () => {
95
+ it('should return null when no state or error in URL', async () => {
96
+ mocks.window.location.search = '';
97
+
98
+ const result = await YouVersionAPIUsers.handleAuthCallback();
99
+ expect(result).toBeNull();
100
+ });
101
+
102
+ it('should throw error when OAuth error is present', async () => {
103
+ mocks.window.location.search = '?error=access_denied&error_description=User denied access';
104
+
105
+ await expect(YouVersionAPIUsers.handleAuthCallback()).rejects.toThrow(
106
+ 'OAuth authentication failed: User denied access',
107
+ );
108
+ });
109
+
110
+ it('should throw error when OAuth error is present without description', async () => {
111
+ mocks.window.location.search = '?error=server_error';
112
+
113
+ await expect(YouVersionAPIUsers.handleAuthCallback()).rejects.toThrow(
114
+ 'OAuth authentication failed: server_error',
115
+ );
116
+ });
117
+
118
+ it('should throw error when state parameter is invalid', async () => {
119
+ mocks.window.location.search = '?state=invalid-state&code=auth-code';
120
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
121
+ if (key === 'youversion-auth-state') return 'valid-state';
122
+ return null;
123
+ });
124
+
125
+ await expect(YouVersionAPIUsers.handleAuthCallback()).rejects.toThrow(
126
+ 'Invalid state parameter - possible CSRF attack',
127
+ );
128
+ });
129
+
130
+ it('should redirect to server when state exists but no code', async () => {
131
+ mocks.window.location.href = 'https://example.com/callback?state=test-state';
132
+ mocks.window.location.search = '?state=test-state';
133
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
134
+ if (key === 'youversion-auth-state') return 'test-state';
135
+ return null;
136
+ });
137
+
138
+ // In test environment, the redirect continues execution, so expect the eventual error
139
+ await expect(YouVersionAPIUsers.handleAuthCallback()).rejects.toThrow();
140
+
141
+ // Verify that the redirect was attempted
142
+ expect(mocks.window.location.href).toBe(
143
+ 'https://api.youversion.com/auth/callback?state=test-state',
144
+ );
145
+ });
146
+
147
+ it('should throw error when required parameters are missing', async () => {
148
+ mocks.window.location.search = '?state=test-state&code=auth-code';
149
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
150
+ if (key === 'youversion-auth-state') return 'test-state';
151
+ return null; // Missing other required parameters
152
+ });
153
+
154
+ await expect(YouVersionAPIUsers.handleAuthCallback()).rejects.toThrow(
155
+ 'Missing required authentication parameters',
156
+ );
157
+ });
158
+
159
+ it('should successfully exchange code for tokens', async () => {
160
+ const mockTokens = {
161
+ access_token: 'access-token-123',
162
+ expires_in: 3600,
163
+ id_token:
164
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJlbWFpbCI6ImpvaG5AZXhhbXBsZS5jb20iLCJwcm9maWxlX3BpY3R1cmUiOiJodHRwczovL2V4YW1wbGUuY29tL2F2YXRhci5qcGcifQ.invalid-signature',
165
+ refresh_token: 'refresh-token-456',
166
+ scope: 'bibles highlights openid',
167
+ token_type: 'Bearer',
168
+ };
169
+
170
+ const mockResponse = {
171
+ ok: true,
172
+ status: 200,
173
+ statusText: 'OK',
174
+ text: vi.fn().mockResolvedValue(JSON.stringify(mockTokens)),
175
+ };
176
+
177
+ mocks.window.location.search = '?state=test-state&code=auth-code';
178
+ mocks.window.location.href = 'https://example.com/callback?state=test-state&code=auth-code';
179
+
180
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
181
+ switch (key) {
182
+ case 'youversion-auth-state':
183
+ return 'test-state';
184
+ case 'youversion-auth-code-verifier':
185
+ return 'code-verifier-123';
186
+ case 'youversion-auth-redirect-uri':
187
+ return 'https://example.com/callback';
188
+ default:
189
+ return null;
190
+ }
191
+ });
192
+
193
+ mockFetch.mockResolvedValue(mockResponse);
194
+
195
+ // Mock atob for JWT decoding
196
+ vi.mocked(atob).mockReturnValue(
197
+ JSON.stringify({
198
+ sub: '1234567890',
199
+ name: 'John Doe',
200
+ email: 'john@example.com',
201
+ profile_picture: 'https://example.com/avatar.jpg',
202
+ }),
203
+ );
204
+
205
+ // Mock YouVersionPlatformConfiguration.saveAuthData
206
+ const saveAuthDataSpy = vi.spyOn(YouVersionPlatformConfiguration, 'saveAuthData');
207
+
208
+ const result = await YouVersionAPIUsers.handleAuthCallback();
209
+
210
+ expect(result).toBeTruthy();
211
+ expect(result?.accessToken).toBe('access-token-123');
212
+ expect(result?.refreshToken).toBe('refresh-token-456');
213
+ expect(result?.permissions).toEqual(['bibles', 'highlights']);
214
+ expect(result?.yvpUserId).toBe('1234567890');
215
+ expect(result?.name).toBe('John Doe');
216
+ expect(result?.email).toBe('john@example.com');
217
+
218
+ // Verify saveAuthData was called
219
+ expect(saveAuthDataSpy).toHaveBeenCalled();
220
+
221
+ // Verify cleanup
222
+ expect(mocks.localStorage.removeItem).toHaveBeenCalledWith('youversion-auth-code-verifier');
223
+ expect(mocks.localStorage.removeItem).toHaveBeenCalledWith('youversion-auth-redirect-uri');
224
+ expect(mocks.localStorage.removeItem).toHaveBeenCalledWith('youversion-auth-state');
225
+
226
+ expect(mocks.window.history.replaceState).toHaveBeenCalledWith(
227
+ {},
228
+ '',
229
+ 'https://example.com/callback',
230
+ );
231
+
232
+ saveAuthDataSpy.mockRestore();
233
+ });
234
+
235
+ it('should handle token exchange failure', async () => {
236
+ const mockResponse = {
237
+ ok: false,
238
+ status: 400,
239
+ statusText: 'Bad Request',
240
+ };
241
+
242
+ mocks.window.location.search = '?state=test-state&code=auth-code';
243
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
244
+ switch (key) {
245
+ case 'youversion-auth-state':
246
+ return 'test-state';
247
+ case 'youversion-auth-code-verifier':
248
+ return 'code-verifier-123';
249
+ case 'youversion-auth-redirect-uri':
250
+ return 'https://example.com/callback';
251
+ default:
252
+ return null;
253
+ }
254
+ });
255
+
256
+ mockFetch.mockResolvedValue(mockResponse);
257
+
258
+ await expect(YouVersionAPIUsers.handleAuthCallback()).rejects.toThrow(
259
+ 'Token exchange failed: 400 Bad Request',
260
+ );
261
+
262
+ // Verify cleanup on error
263
+ expect(mocks.localStorage.removeItem).toHaveBeenCalledWith('youversion-auth-code-verifier');
264
+ expect(mocks.localStorage.removeItem).toHaveBeenCalledWith('youversion-auth-redirect-uri');
265
+ expect(mocks.localStorage.removeItem).toHaveBeenCalledWith('youversion-auth-state');
266
+ });
267
+ });
268
+
269
+ describe('obtainLocation', () => {
270
+ it('should redirect to server callback with correct parameters', () => {
271
+ YouVersionPlatformConfiguration.apiHost = 'api-test.youversion.com';
272
+
273
+ const callbackURL = 'https://example.com/callback?state=test-state&user=123';
274
+ const state = 'test-state';
275
+
276
+ // Access private method
277
+ // @ts-expect-error - accessing private method for testing
278
+ YouVersionAPIUsers.obtainLocation(callbackURL, state);
279
+
280
+ expect(mocks.window.location.href).toBe(
281
+ 'https://api-test.youversion.com/auth/callback?state=test-state&user=123',
282
+ );
283
+ });
284
+
285
+ it('should throw error when state parameter is invalid', () => {
286
+ const callbackURL = 'https://example.com/callback?state=invalid-state';
287
+ const state = 'valid-state';
288
+
289
+ expect(() => {
290
+ // @ts-expect-error - accessing private method for testing
291
+ YouVersionAPIUsers.obtainLocation(callbackURL, state);
292
+ }).toThrow('Invalid state parameter');
293
+ });
294
+ });
295
+
296
+ describe('extractSignInResult', () => {
297
+ it('should extract sign-in result from tokens', () => {
298
+ const fixedDate = new Date(2025, 2, 11, 12, 0, 0);
299
+ vi.setSystemTime(fixedDate);
300
+ const tokens = {
301
+ access_token: 'access-token-123',
302
+ expires_in: 3600,
303
+ id_token:
304
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJlbWFpbCI6ImpvaG5AZXhhbXBsZS5jb20iLCJwcm9maWxlX3BpY3R1cmUiOiJodHRwczovL2V4YW1wbGUuY29tL2F2YXRhci5qcGcifQ.invalid',
305
+ refresh_token: 'refresh-token-456',
306
+ scope: 'bibles highlights openid',
307
+ token_type: 'Bearer',
308
+ };
309
+
310
+ // Mock JWT decoding
311
+ vi.mocked(atob).mockReturnValue(
312
+ JSON.stringify({
313
+ sub: '1234567890',
314
+ name: 'John Doe',
315
+ email: 'john@example.com',
316
+ profile_picture: 'https://example.com/avatar.jpg',
317
+ }),
318
+ );
319
+
320
+ // @ts-expect-error - accessing private method for testing
321
+ const result = YouVersionAPIUsers.extractSignInResult(tokens);
322
+
323
+ expect(result.accessToken).toBe('access-token-123');
324
+ expect(result.expiryDate).toStrictEqual(new Date(fixedDate.getTime() + 60 * 60 * 1000));
325
+ expect(result.refreshToken).toBe('refresh-token-456');
326
+ expect(result.permissions).toEqual(['bibles', 'highlights']);
327
+ expect(result.yvpUserId).toBe('1234567890');
328
+ expect(result.name).toBe('John Doe');
329
+ expect(result.email).toBe('john@example.com');
330
+ expect(result.profilePicture).toBe('https://example.com/avatar.jpg');
331
+ });
332
+
333
+ it('should filter out unknown permissions', () => {
334
+ const tokens = {
335
+ access_token: 'token',
336
+ expires_in: 3600,
337
+ id_token:
338
+ 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.e30.Et9HFtf9R3GEMA0IICOfFMVXY7kkTX1wr4qCyhIf58U',
339
+ refresh_token: 'refresh',
340
+ scope: 'bibles unknown_permission highlights invalid_scope',
341
+ token_type: 'Bearer',
342
+ };
343
+
344
+ // Mock JWT decoding
345
+ vi.mocked(atob).mockReturnValue(JSON.stringify({ sub: 'user123' }));
346
+
347
+ // @ts-expect-error - accessing private method for testing
348
+ const result = YouVersionAPIUsers.extractSignInResult(tokens);
349
+
350
+ expect(result.permissions).toEqual(['bibles', 'highlights']);
351
+ });
352
+ });
353
+
354
+ describe('decodeJWT', () => {
355
+ it('should decode valid JWT token', () => {
356
+ const payload = { sub: '123', name: 'John' };
357
+ const base64Payload = String(mocks.btoa(JSON.stringify(payload)) || 'mockbase64');
358
+ const token = `header.${base64Payload}.signature`;
359
+
360
+ vi.mocked(atob).mockReturnValue(JSON.stringify(payload));
361
+
362
+ // @ts-expect-error - accessing private method for testing
363
+ const result = YouVersionAPIUsers.decodeJWT(token);
364
+
365
+ expect(result).toEqual(payload);
366
+ });
367
+
368
+ it('should return empty object for invalid token format', () => {
369
+ // @ts-expect-error - accessing private method for testing
370
+ const result = YouVersionAPIUsers.decodeJWT('invalid.token');
371
+
372
+ expect(result).toEqual({});
373
+ });
374
+
375
+ it('should handle base64 padding correctly', () => {
376
+ const payload = { sub: '123' };
377
+ const base64Payload = 'eyJzdWIiOiIxMjMifQ'; // Base64 without padding
378
+ const token = `header.${base64Payload}.signature`;
379
+
380
+ vi.mocked(atob).mockReturnValue(JSON.stringify(payload));
381
+
382
+ // @ts-expect-error - accessing private method for testing
383
+ const result = YouVersionAPIUsers.decodeJWT(token);
384
+
385
+ expect(result).toEqual(payload);
386
+ });
387
+
388
+ it('should return empty object when atob throws', () => {
389
+ const token = 'header.invalid-base64.signature';
390
+ vi.mocked(atob).mockImplementation(() => {
391
+ throw new Error('Invalid base64');
392
+ });
393
+
394
+ // @ts-expect-error - accessing private method for testing
395
+ const result = YouVersionAPIUsers.decodeJWT(token);
396
+
397
+ expect(result).toEqual({});
398
+ });
399
+
400
+ it('should return empty object when JSON.parse throws', () => {
401
+ const token = 'header.dmFsaWRiYXNlNjQ.signature';
402
+ vi.mocked(atob).mockReturnValue('invalid json');
403
+
404
+ // @ts-expect-error - accessing private method for testing
405
+ const result = YouVersionAPIUsers.decodeJWT(token);
406
+
407
+ expect(result).toEqual({});
408
+ });
409
+ });
410
+
411
+ describe('signOut', () => {
412
+ it('should call setAccessToken with null', () => {
413
+ const setAccessTokenSpy = vi.spyOn(YouVersionPlatformConfiguration, 'clearAuthTokens');
414
+
415
+ YouVersionAPIUsers.signOut();
416
+
417
+ expect(setAccessTokenSpy).toHaveBeenCalled();
418
+
419
+ setAccessTokenSpy.mockRestore();
420
+ });
421
+ });
422
+
423
+ describe('userInfo', () => {
424
+ it('should throw error for invalid access token', () => {
425
+ expect(() => YouVersionAPIUsers.userInfo('')).toThrow(
426
+ 'Invalid access token: must be a non-empty string',
427
+ );
428
+
429
+ expect(() => {
430
+ // @ts-expect-error - Testing invalid input type
431
+ YouVersionAPIUsers.userInfo(null);
432
+ }).toThrow('Invalid access token: must be a non-empty string');
433
+ });
434
+
435
+ it('should decode user info from valid JWT token', () => {
436
+ const claims = {
437
+ sub: 'user123',
438
+ name: 'John Doe',
439
+ profile_picture: 'https://example.com/avatar.jpg',
440
+ email: 'john@example.com',
441
+ };
442
+
443
+ vi.mocked(atob).mockReturnValue(JSON.stringify(claims));
444
+
445
+ const result = YouVersionAPIUsers.userInfo('valid.jwt.token');
446
+
447
+ expect(result).toBeInstanceOf(YouVersionUserInfo);
448
+ expect(result.userId).toBe('user123');
449
+ expect(result.name).toBe('John Doe');
450
+ expect(result.email).toBe('john@example.com');
451
+ expect(result.avatarUrl).toStrictEqual(new URL('https://example.com/avatar.jpg'));
452
+ });
453
+
454
+ it('should throw error for empty JWT claims', () => {
455
+ vi.mocked(atob).mockReturnValue('{}');
456
+
457
+ expect(() => YouVersionAPIUsers.userInfo('valid.jwt.token')).toThrow(
458
+ 'Invalid JWT token: Unable to decode token payload',
459
+ );
460
+ });
461
+
462
+ it('should throw error when JWT decoding fails', () => {
463
+ vi.mocked(atob).mockImplementation(() => {
464
+ throw new Error('Invalid base64');
465
+ });
466
+
467
+ expect(() => YouVersionAPIUsers.userInfo('invalid.jwt.token')).toThrow(
468
+ 'Invalid JWT token: Unable to decode token payload',
469
+ );
470
+ });
471
+ });
472
+
473
+ describe('refreshTokens', () => {
474
+ beforeEach(() => {
475
+ vi.clearAllMocks();
476
+ });
477
+
478
+ it('should throw error when no refresh token available', async () => {
479
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
480
+ if (key === 'refreshToken') return null;
481
+ if (key === 'idToken') return 'id-token-123';
482
+ return null;
483
+ });
484
+
485
+ await expect(YouVersionAPIUsers.refreshTokens()).rejects.toThrow(
486
+ 'No refresh token or id token available',
487
+ );
488
+ });
489
+
490
+ it('should throw error when no id token available', async () => {
491
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
492
+ if (key === 'refreshToken') return 'refresh-token-123';
493
+ if (key === 'idToken') return null;
494
+ return null;
495
+ });
496
+
497
+ await expect(YouVersionAPIUsers.refreshTokens()).rejects.toThrow(
498
+ 'No refresh token or id token available',
499
+ );
500
+ });
501
+
502
+ it('should throw error when appKey is not set', async () => {
503
+ YouVersionPlatformConfiguration.appKey = null;
504
+
505
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
506
+ if (key === 'refreshToken') return 'refresh-token-123';
507
+ if (key === 'idToken') return 'id-token-123';
508
+ return null;
509
+ });
510
+
511
+ await expect(YouVersionAPIUsers.refreshTokens()).rejects.toThrow(
512
+ 'YouVersionPlatformConfiguration.appKey must be set before refreshing tokens',
513
+ );
514
+ });
515
+
516
+ it('should successfully refresh tokens and preserve existing id_token', async () => {
517
+ const originalAccessToken = 'old-access-token';
518
+ const originalRefreshToken = 'old-refresh-token';
519
+ const existingIdToken = 'existing-id-token';
520
+
521
+ const mockRefreshResponse = {
522
+ access_token: 'new-access-token',
523
+ expires_in: 3600,
524
+ refresh_token: 'new-refresh-token',
525
+ scope: 'bibles highlights openid',
526
+ token_type: 'Bearer',
527
+ };
528
+
529
+ const mockResponse = {
530
+ ok: true,
531
+ status: 200,
532
+ statusText: 'OK',
533
+ json: vi.fn().mockResolvedValue(mockRefreshResponse),
534
+ };
535
+
536
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
537
+ if (key === 'refreshToken') return originalRefreshToken;
538
+ if (key === 'idToken') return existingIdToken;
539
+ if (key === 'accessToken') return originalAccessToken;
540
+ return null;
541
+ });
542
+
543
+ mockFetch.mockResolvedValue(mockResponse);
544
+
545
+ const saveAuthDataSpy = vi.spyOn(YouVersionPlatformConfiguration, 'saveAuthData');
546
+
547
+ const result = await YouVersionAPIUsers.refreshTokens();
548
+
549
+ expect(result).toBeTruthy();
550
+
551
+ // Assert that access_token and refresh_token are new (different from original)
552
+ expect(result?.accessToken).toBe('new-access-token');
553
+ expect(result?.accessToken).not.toBe(originalAccessToken);
554
+ expect(result?.refreshToken).toBe('new-refresh-token');
555
+ expect(result?.refreshToken).not.toBe(originalRefreshToken);
556
+
557
+ // Assert that id_token is preserved (same as original)
558
+ expect(result?.idToken).toBe(existingIdToken);
559
+
560
+ expect(result?.permissions).toEqual(['bibles', 'highlights']);
561
+
562
+ // Verify the refresh token request was made correctly
563
+ expect(mockFetch).toHaveBeenCalledTimes(1);
564
+ expect(mockFetch).toHaveBeenCalledWith(
565
+ expect.objectContaining({
566
+ method: 'POST',
567
+ url: 'https://api.youversion.com/auth/token',
568
+ }),
569
+ );
570
+
571
+ const [calledRequest] = mockFetch.mock.calls[0] as [Request];
572
+ expect(calledRequest).toBeInstanceOf(Request);
573
+ expect(calledRequest.method).toBe('POST');
574
+ expect(calledRequest.url).toBe('https://api.youversion.com/auth/token');
575
+ expect(calledRequest.headers.get('content-type')).toBe('application/x-www-form-urlencoded');
576
+ const bodyText = await calledRequest.clone().text();
577
+ const body = new URLSearchParams(bodyText);
578
+ expect(body.get('grant_type')).toBe('refresh_token');
579
+
580
+ // Verify saveAuthData was called with new tokens but existing id_token
581
+ expect(saveAuthDataSpy).toHaveBeenCalledWith(
582
+ 'new-access-token',
583
+ 'new-refresh-token',
584
+ existingIdToken,
585
+ expect.any(Date),
586
+ );
587
+
588
+ saveAuthDataSpy.mockRestore();
589
+ });
590
+
591
+ it('should handle refresh token request failure', async () => {
592
+ const mockResponse = {
593
+ ok: false,
594
+ status: 401,
595
+ statusText: 'Unauthorized',
596
+ };
597
+
598
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
599
+ if (key === 'refreshToken') return 'refresh-token-123';
600
+ if (key === 'idToken') return 'id-token-123';
601
+ return null;
602
+ });
603
+
604
+ mockFetch.mockResolvedValue(mockResponse);
605
+
606
+ await expect(YouVersionAPIUsers.refreshTokens()).rejects.toThrow(
607
+ 'Token refresh failed: 401 Unauthorized',
608
+ );
609
+ });
610
+
611
+ it('should handle network errors during refresh', async () => {
612
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
613
+ if (key === 'refreshToken') return 'refresh-token-123';
614
+ if (key === 'idToken') return 'id-token-123';
615
+ return null;
616
+ });
617
+
618
+ mockFetch.mockRejectedValue(new Error('Network error'));
619
+
620
+ await expect(YouVersionAPIUsers.refreshTokens()).rejects.toThrow(
621
+ 'Token refresh failed: Network error',
622
+ );
623
+ });
624
+ });
625
+
626
+ describe('isTokenExpired', () => {
627
+ beforeEach(() => {
628
+ vi.clearAllMocks();
629
+ });
630
+
631
+ it('should return true when no expiry date is available', () => {
632
+ mocks.localStorage.getItem.mockReturnValue(null);
633
+
634
+ const result = YouVersionAPIUsers.isTokenExpired();
635
+
636
+ expect(result).toBe(true);
637
+ });
638
+
639
+ it('should return false when token is not expired', () => {
640
+ const futureDate = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes from now
641
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
642
+ if (key === 'expiryDate') return futureDate.toISOString();
643
+ return null;
644
+ });
645
+
646
+ const result = YouVersionAPIUsers.isTokenExpired();
647
+
648
+ expect(result).toBe(false);
649
+ });
650
+
651
+ it('should return true when token is expired', () => {
652
+ const pastDate = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago
653
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
654
+ if (key === 'expiryDate') return pastDate.toISOString();
655
+ return null;
656
+ });
657
+
658
+ const result = YouVersionAPIUsers.isTokenExpired();
659
+
660
+ expect(result).toBe(true);
661
+ });
662
+ });
663
+
664
+ describe('refreshTokenIfNeeded', () => {
665
+ beforeEach(() => {
666
+ vi.clearAllMocks();
667
+ });
668
+
669
+ it('should return true when token is not expired', async () => {
670
+ const futureDate = new Date(Date.now() + 10 * 60 * 1000);
671
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
672
+ if (key === 'expiryDate') return futureDate.toISOString();
673
+ return null;
674
+ });
675
+
676
+ const result = await YouVersionAPIUsers.refreshTokenIfNeeded();
677
+
678
+ expect(result).toBe(true);
679
+ expect(mockFetch).not.toHaveBeenCalled();
680
+ });
681
+
682
+ it('should refresh tokens when expired and return true on success', async () => {
683
+ const pastDate = new Date(Date.now() - 10 * 60 * 1000);
684
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
685
+ if (key === 'expiryDate') return pastDate.toISOString();
686
+ if (key === 'refreshToken') return 'refresh-token-123';
687
+ if (key === 'idToken') return 'id-token-123';
688
+ return null;
689
+ });
690
+
691
+ const mockRefreshResponse = {
692
+ access_token: 'new-access-token',
693
+ expires_in: 3600,
694
+ refresh_token: 'new-refresh-token',
695
+ scope: 'bibles highlights openid',
696
+ token_type: 'Bearer',
697
+ };
698
+
699
+ mockFetch.mockResolvedValue({
700
+ ok: true,
701
+ status: 200,
702
+ statusText: 'OK',
703
+ json: vi.fn().mockResolvedValue(mockRefreshResponse),
704
+ });
705
+
706
+ const result = await YouVersionAPIUsers.refreshTokenIfNeeded();
707
+
708
+ expect(result).toBe(true);
709
+ expect(mockFetch).toHaveBeenCalled();
710
+ });
711
+
712
+ it('should return false and clear tokens when refresh fails', async () => {
713
+ const pastDate = new Date(Date.now() - 10 * 60 * 1000);
714
+ mocks.localStorage.getItem.mockImplementation((key: string) => {
715
+ if (key === 'expiryDate') return pastDate.toISOString();
716
+ if (key === 'refreshToken') return 'refresh-token-123';
717
+ if (key === 'idToken') return 'id-token-123';
718
+ return null;
719
+ });
720
+
721
+ mockFetch.mockResolvedValue({
722
+ ok: false,
723
+ status: 401,
724
+ statusText: 'Unauthorized',
725
+ });
726
+
727
+ const clearAuthTokensSpy = vi.spyOn(YouVersionPlatformConfiguration, 'clearAuthTokens');
728
+
729
+ const result = await YouVersionAPIUsers.refreshTokenIfNeeded();
730
+
731
+ expect(result).toBe(false);
732
+ expect(clearAuthTokensSpy).toHaveBeenCalled();
733
+
734
+ clearAuthTokensSpy.mockRestore();
735
+ });
736
+ });
737
+ });