@youversion/platform-react-hooks 0.6.0 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +38 -0
- package/README.md +3 -3
- package/dist/__tests__/mocks/auth.d.ts +12 -0
- package/dist/__tests__/mocks/auth.d.ts.map +1 -0
- package/dist/__tests__/mocks/auth.js +44 -0
- package/dist/__tests__/mocks/auth.js.map +1 -0
- package/dist/__tests__/utils/test-utils.d.ts +15 -0
- package/dist/__tests__/utils/test-utils.d.ts.map +1 -0
- package/dist/__tests__/utils/test-utils.js +24 -0
- package/dist/__tests__/utils/test-utils.js.map +1 -0
- package/dist/context/YouVersionAuthContext.d.ts +4 -0
- package/dist/context/YouVersionAuthContext.d.ts.map +1 -0
- package/dist/context/YouVersionAuthContext.js +13 -0
- package/dist/context/YouVersionAuthContext.js.map +1 -0
- package/dist/context/YouVersionAuthProvider.d.ts +8 -0
- package/dist/context/YouVersionAuthProvider.d.ts.map +1 -0
- package/dist/context/YouVersionAuthProvider.js +80 -0
- package/dist/context/YouVersionAuthProvider.js.map +1 -0
- package/dist/context/YouVersionContext.d.ts +8 -0
- package/dist/context/YouVersionContext.d.ts.map +1 -0
- package/dist/context/YouVersionContext.js +4 -0
- package/dist/context/YouVersionContext.js.map +1 -0
- package/dist/context/YouVersionProvider.d.ts +17 -0
- package/dist/context/YouVersionProvider.d.ts.map +1 -0
- package/dist/context/YouVersionProvider.js +25 -0
- package/dist/context/YouVersionProvider.js.map +1 -0
- package/dist/context/index.d.ts +2 -2
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +2 -2
- package/dist/context/index.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/types/auth.d.ts +18 -0
- package/dist/types/auth.d.ts.map +1 -0
- package/dist/types/auth.js +2 -0
- package/dist/types/auth.js.map +1 -0
- package/dist/useBibleClient.js +3 -3
- package/dist/useBibleClient.js.map +1 -1
- package/dist/useHighlights.js +3 -3
- package/dist/useHighlights.js.map +1 -1
- package/dist/useLanguages.js +3 -3
- package/dist/useLanguages.js.map +1 -1
- package/dist/usePassage.d.ts +3 -3
- package/dist/usePassage.d.ts.map +1 -1
- package/dist/usePassage.js +5 -5
- package/dist/usePassage.js.map +1 -1
- package/dist/useYVAuth.d.ts +97 -0
- package/dist/useYVAuth.d.ts.map +1 -0
- package/dist/useYVAuth.js +135 -0
- package/dist/useYVAuth.js.map +1 -0
- package/package.json +5 -5
- package/src/__tests__/mocks/auth.ts +48 -0
- package/src/__tests__/utils/test-utils.tsx +43 -0
- package/src/context/YouVersionAuthContext.test.tsx +88 -0
- package/src/context/YouVersionAuthContext.tsx +20 -0
- package/src/context/YouVersionAuthProvider.test.tsx +373 -0
- package/src/context/YouVersionAuthProvider.tsx +90 -0
- package/src/context/{BibleSDKContext.tsx → YouVersionContext.tsx} +2 -2
- package/src/context/YouVersionProvider.tsx +65 -0
- package/src/context/index.ts +2 -2
- package/src/index.ts +6 -0
- package/src/types/auth.ts +20 -0
- package/src/useBibleClient.test.tsx +14 -14
- package/src/useBibleClient.ts +3 -3
- package/src/useHighlights.test.tsx +6 -6
- package/src/useHighlights.ts +3 -3
- package/src/useLanguages.test.tsx +6 -6
- package/src/useLanguages.ts +3 -3
- package/src/usePassage.ts +8 -15
- package/src/useVOTD.test.tsx +6 -6
- package/src/useYVAuth.test.tsx +378 -0
- package/src/useYVAuth.ts +179 -0
- package/dist/context/BibleSDKContext.d.ts +0 -8
- package/dist/context/BibleSDKContext.d.ts.map +0 -1
- package/dist/context/BibleSDKContext.js +0 -4
- package/dist/context/BibleSDKContext.js.map +0 -1
- package/dist/context/BibleSDKProvider.d.ts +0 -9
- package/dist/context/BibleSDKProvider.d.ts.map +0 -1
- package/dist/context/BibleSDKProvider.js +0 -18
- package/dist/context/BibleSDKProvider.js.map +0 -1
- package/src/context/BibleSDKProvider.tsx +0 -35
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/unbound-method, @typescript-eslint/no-unsafe-argument */
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { render } from '@testing-library/react';
|
|
4
|
+
import { YouVersionAPIUsers, YouVersionPlatformConfiguration } from '@youversion/platform-core';
|
|
5
|
+
import YouVersionAuthProvider from './YouVersionAuthProvider';
|
|
6
|
+
import { useYouVersionAuthContext } from './YouVersionAuthContext';
|
|
7
|
+
import type { AuthConfig } from '../types/auth';
|
|
8
|
+
import { createMockUserInfo, createMockAuthResult } from '../__tests__/mocks/auth';
|
|
9
|
+
|
|
10
|
+
// Mock the core modules
|
|
11
|
+
vi.mock('@youversion/platform-core', () => {
|
|
12
|
+
let mockInstallationId = 'auto-generated-installation-id';
|
|
13
|
+
let mockIdToken: string | null = null;
|
|
14
|
+
let mockRefreshToken: string | null = null;
|
|
15
|
+
let mockAccessToken: string | null = null;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
YouVersionAPIUsers: {
|
|
19
|
+
handleAuthCallback: vi.fn(),
|
|
20
|
+
userInfo: vi.fn(),
|
|
21
|
+
refreshTokenIfNeeded: vi.fn(),
|
|
22
|
+
},
|
|
23
|
+
YouVersionPlatformConfiguration: {
|
|
24
|
+
appKey: '',
|
|
25
|
+
get installationId() {
|
|
26
|
+
return mockInstallationId;
|
|
27
|
+
},
|
|
28
|
+
set installationId(value) {
|
|
29
|
+
if (value) mockInstallationId = value;
|
|
30
|
+
},
|
|
31
|
+
apiHost: 'test-api.example.com',
|
|
32
|
+
get idToken() {
|
|
33
|
+
return mockIdToken;
|
|
34
|
+
},
|
|
35
|
+
get refreshToken() {
|
|
36
|
+
return mockRefreshToken;
|
|
37
|
+
},
|
|
38
|
+
get accessToken() {
|
|
39
|
+
return mockAccessToken;
|
|
40
|
+
},
|
|
41
|
+
clearAuthTokens: vi.fn(() => {
|
|
42
|
+
mockIdToken = null;
|
|
43
|
+
mockRefreshToken = null;
|
|
44
|
+
mockAccessToken = null;
|
|
45
|
+
}),
|
|
46
|
+
saveAuthData: vi.fn(
|
|
47
|
+
(accessToken: string | null, refreshToken: string | null, idToken: string | null) => {
|
|
48
|
+
mockAccessToken = accessToken;
|
|
49
|
+
mockRefreshToken = refreshToken;
|
|
50
|
+
mockIdToken = idToken;
|
|
51
|
+
},
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
YouVersionUserInfo: class YouVersionUserInfo {
|
|
55
|
+
readonly name?: string;
|
|
56
|
+
readonly userId?: string;
|
|
57
|
+
readonly email?: string;
|
|
58
|
+
readonly avatarUrlFormat?: string;
|
|
59
|
+
|
|
60
|
+
constructor(data: any) {
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
62
|
+
this.name = data.name;
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
64
|
+
this.userId = data.id;
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
66
|
+
this.email = data.email;
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
|
|
68
|
+
this.avatarUrlFormat = data.avatar_url;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getAvatarUrl(width: number = 200, height: number = 200): URL | null {
|
|
72
|
+
if (!this.avatarUrlFormat) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
let urlString = this.avatarUrlFormat;
|
|
77
|
+
urlString = urlString.replace('{width}', width.toString());
|
|
78
|
+
urlString = urlString.replace('{height}', height.toString());
|
|
79
|
+
return new URL(urlString);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get avatarUrl(): URL | null {
|
|
86
|
+
return this.getAvatarUrl();
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
SignInWithYouVersionResult: class SignInWithYouVersionResult {
|
|
90
|
+
accessToken: string | undefined;
|
|
91
|
+
expiryDate: Date | undefined;
|
|
92
|
+
refreshToken: string | undefined;
|
|
93
|
+
idToken: string | undefined;
|
|
94
|
+
permissions: string[] | undefined;
|
|
95
|
+
yvpUserId: string | undefined;
|
|
96
|
+
name: string | undefined;
|
|
97
|
+
profilePicture: string | undefined;
|
|
98
|
+
email: string | undefined;
|
|
99
|
+
|
|
100
|
+
constructor(props: {
|
|
101
|
+
accessToken?: string;
|
|
102
|
+
expiresIn?: number;
|
|
103
|
+
refreshToken?: string;
|
|
104
|
+
idToken?: string;
|
|
105
|
+
permissions?: string[];
|
|
106
|
+
yvpUserId?: string;
|
|
107
|
+
name?: string;
|
|
108
|
+
profilePicture?: string;
|
|
109
|
+
email?: string;
|
|
110
|
+
}) {
|
|
111
|
+
this.accessToken = props.accessToken;
|
|
112
|
+
this.expiryDate = props.expiresIn
|
|
113
|
+
? new Date(Date.now() + props.expiresIn * 1000)
|
|
114
|
+
: new Date();
|
|
115
|
+
this.refreshToken = props.refreshToken;
|
|
116
|
+
this.idToken = props.idToken;
|
|
117
|
+
this.permissions = props.permissions;
|
|
118
|
+
this.yvpUserId = props.yvpUserId;
|
|
119
|
+
this.name = props.name;
|
|
120
|
+
this.profilePicture = props.profilePicture;
|
|
121
|
+
this.email = props.email;
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const mockConfig: AuthConfig = {
|
|
128
|
+
appKey: 'test-app-key',
|
|
129
|
+
apiHost: 'test-api.example.com',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const mockUserInfo = createMockUserInfo();
|
|
133
|
+
const mockAuthResult = createMockAuthResult();
|
|
134
|
+
|
|
135
|
+
// Mock window and location
|
|
136
|
+
const mockWindow = {
|
|
137
|
+
location: {
|
|
138
|
+
href: 'https://example.com',
|
|
139
|
+
search: '',
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Test component to access context
|
|
144
|
+
function TestChild() {
|
|
145
|
+
const { userInfo, isLoading, error } = useYouVersionAuthContext();
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div>
|
|
149
|
+
<div data-testid="user-info">{userInfo ? JSON.stringify(userInfo) : 'null'}</div>
|
|
150
|
+
<div data-testid="is-loading">{isLoading.toString()}</div>
|
|
151
|
+
<div data-testid="error">{error ? error.message : 'null'}</div>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
describe('YouVersionAuthProvider', () => {
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
vi.clearAllMocks();
|
|
159
|
+
|
|
160
|
+
// Setup window mock
|
|
161
|
+
vi.stubGlobal('window', mockWindow);
|
|
162
|
+
mockWindow.location.search = '';
|
|
163
|
+
|
|
164
|
+
// Reset configuration
|
|
165
|
+
YouVersionPlatformConfiguration.appKey = '';
|
|
166
|
+
YouVersionPlatformConfiguration.apiHost = 'test-api.example.com';
|
|
167
|
+
YouVersionPlatformConfiguration.clearAuthTokens();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
vi.clearAllMocks();
|
|
172
|
+
vi.unstubAllGlobals();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('initialization', () => {
|
|
176
|
+
it('should configure YouVersionPlatformConfiguration on mount', async () => {
|
|
177
|
+
render(
|
|
178
|
+
<YouVersionAuthProvider config={mockConfig}>
|
|
179
|
+
<TestChild />
|
|
180
|
+
</YouVersionAuthProvider>,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
// Wait for async initialization to complete
|
|
184
|
+
await vi.waitFor(() => {
|
|
185
|
+
expect(YouVersionPlatformConfiguration.appKey).toBe(mockConfig.appKey);
|
|
186
|
+
expect(YouVersionPlatformConfiguration.apiHost).toBe(mockConfig.apiHost);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should use default apiHost when not provided', async () => {
|
|
191
|
+
const configWithoutApiHost = {
|
|
192
|
+
appKey: 'test-app-key',
|
|
193
|
+
installationId: 'test-installation-id',
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
render(
|
|
197
|
+
<YouVersionAuthProvider config={configWithoutApiHost}>
|
|
198
|
+
<TestChild />
|
|
199
|
+
</YouVersionAuthProvider>,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
await vi.waitFor(() => {
|
|
203
|
+
expect(YouVersionPlatformConfiguration.appKey).toBe('test-app-key');
|
|
204
|
+
expect(YouVersionPlatformConfiguration.installationId).toBeTruthy();
|
|
205
|
+
// Since config had no apiHost, component should set default (in real implementation this would be 'api.youversion.com')
|
|
206
|
+
// But since we're mocking, we can test that it gets set to something defined
|
|
207
|
+
expect(YouVersionPlatformConfiguration.apiHost).toBeTruthy();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should handle null installationId', async () => {
|
|
212
|
+
const configWithoutInstallation = {
|
|
213
|
+
appKey: 'test-app-key',
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
render(
|
|
217
|
+
<YouVersionAuthProvider config={configWithoutInstallation}>
|
|
218
|
+
<TestChild />
|
|
219
|
+
</YouVersionAuthProvider>,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
await vi.waitFor(() => {
|
|
223
|
+
expect(YouVersionPlatformConfiguration.installationId).not.toBe(null);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('OAuth callback handling', () => {
|
|
229
|
+
it('should detect OAuth callback with state parameter', async () => {
|
|
230
|
+
mockWindow.location.search = '?state=test-state&code=auth-code';
|
|
231
|
+
vi.mocked(YouVersionAPIUsers.handleAuthCallback).mockResolvedValue(mockAuthResult as any);
|
|
232
|
+
vi.mocked(YouVersionAPIUsers.userInfo).mockReturnValue(mockUserInfo as any);
|
|
233
|
+
|
|
234
|
+
// Mock the configuration to return the id token after handleAuthCallback
|
|
235
|
+
vi.mocked(YouVersionAPIUsers.handleAuthCallback).mockImplementation(() => {
|
|
236
|
+
YouVersionPlatformConfiguration.saveAuthData(null, null, 'test-id-token', null);
|
|
237
|
+
return Promise.resolve(mockAuthResult as any);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const { getByTestId } = render(
|
|
241
|
+
<YouVersionAuthProvider config={mockConfig}>
|
|
242
|
+
<TestChild />
|
|
243
|
+
</YouVersionAuthProvider>,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
await vi.waitFor(() => {
|
|
247
|
+
expect(getByTestId('is-loading')).toHaveTextContent('false');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
expect(vi.mocked(YouVersionAPIUsers).handleAuthCallback).toHaveBeenCalled();
|
|
251
|
+
expect(vi.mocked(YouVersionAPIUsers).userInfo).toHaveBeenCalledWith('test-id-token');
|
|
252
|
+
expect(getByTestId('user-info')).toHaveTextContent(JSON.stringify(mockUserInfo));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should detect OAuth callback with error parameter', async () => {
|
|
256
|
+
mockWindow.location.search = '?error=access_denied&error_description=User+denied+access';
|
|
257
|
+
vi.mocked(YouVersionAPIUsers.handleAuthCallback).mockResolvedValue(mockAuthResult as any);
|
|
258
|
+
|
|
259
|
+
const { getByTestId } = render(
|
|
260
|
+
<YouVersionAuthProvider config={mockConfig}>
|
|
261
|
+
<TestChild />
|
|
262
|
+
</YouVersionAuthProvider>,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
await vi.waitFor(() => {
|
|
266
|
+
expect(getByTestId('is-loading')).toHaveTextContent('false');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(vi.mocked(YouVersionAPIUsers).handleAuthCallback).toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('should handle callback error and set error state', async () => {
|
|
273
|
+
mockWindow.location.search = '?state=test-state&code=auth-code';
|
|
274
|
+
const callbackError = new Error('Callback processing failed');
|
|
275
|
+
vi.mocked(YouVersionAPIUsers.handleAuthCallback).mockRejectedValue(callbackError);
|
|
276
|
+
|
|
277
|
+
const { getByTestId } = render(
|
|
278
|
+
<YouVersionAuthProvider config={mockConfig}>
|
|
279
|
+
<TestChild />
|
|
280
|
+
</YouVersionAuthProvider>,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
await vi.waitFor(() => {
|
|
284
|
+
expect(getByTestId('error')).toHaveTextContent('Callback processing failed');
|
|
285
|
+
expect(getByTestId('is-loading')).toHaveTextContent('false');
|
|
286
|
+
expect(getByTestId('user-info')).toHaveTextContent('null');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should handle callback with no idToken', async () => {
|
|
291
|
+
mockWindow.location.search = '?state=test-state&code=auth-code';
|
|
292
|
+
vi.mocked(YouVersionAPIUsers.handleAuthCallback).mockResolvedValue(mockAuthResult as any);
|
|
293
|
+
YouVersionPlatformConfiguration.saveAuthData(null, null, null, null);
|
|
294
|
+
|
|
295
|
+
const { getByTestId } = render(
|
|
296
|
+
<YouVersionAuthProvider config={mockConfig}>
|
|
297
|
+
<TestChild />
|
|
298
|
+
</YouVersionAuthProvider>,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
await vi.waitFor(() => {
|
|
302
|
+
expect(getByTestId('is-loading')).toHaveTextContent('false');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
expect(vi.mocked(YouVersionAPIUsers).userInfo).not.toHaveBeenCalled();
|
|
306
|
+
expect(getByTestId('user-info')).toHaveTextContent('null');
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('existing token handling', () => {
|
|
311
|
+
it('should refresh token when refresh token exists', async () => {
|
|
312
|
+
// Set up refresh token before mounting component
|
|
313
|
+
YouVersionPlatformConfiguration.saveAuthData(null, 'existing-refresh-token', null, null);
|
|
314
|
+
|
|
315
|
+
// Mock refreshTokenIfNeeded to set the id token after successful refresh
|
|
316
|
+
vi.mocked(YouVersionAPIUsers.refreshTokenIfNeeded).mockImplementation(() => {
|
|
317
|
+
YouVersionPlatformConfiguration.saveAuthData(null, null, 'refreshed-id-token', null);
|
|
318
|
+
return Promise.resolve(true);
|
|
319
|
+
});
|
|
320
|
+
vi.mocked(YouVersionAPIUsers.userInfo).mockReturnValue(mockUserInfo as any);
|
|
321
|
+
|
|
322
|
+
const { getByTestId } = render(
|
|
323
|
+
<YouVersionAuthProvider config={mockConfig}>
|
|
324
|
+
<TestChild />
|
|
325
|
+
</YouVersionAuthProvider>,
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
await vi.waitFor(() => {
|
|
329
|
+
expect(getByTestId('is-loading')).toHaveTextContent('false');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
expect(vi.mocked(YouVersionAPIUsers).refreshTokenIfNeeded).toHaveBeenCalled();
|
|
333
|
+
expect(vi.mocked(YouVersionAPIUsers).userInfo).toHaveBeenCalledWith('refreshed-id-token');
|
|
334
|
+
expect(getByTestId('user-info')).toHaveTextContent(JSON.stringify(mockUserInfo));
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should handle refresh token failure', async () => {
|
|
338
|
+
YouVersionPlatformConfiguration.saveAuthData(null, 'existing-refresh-token', null, null);
|
|
339
|
+
vi.mocked(YouVersionAPIUsers.refreshTokenIfNeeded).mockRejectedValue(
|
|
340
|
+
new Error('Refresh failed'),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const { getByTestId } = render(
|
|
344
|
+
<YouVersionAuthProvider config={mockConfig}>
|
|
345
|
+
<TestChild />
|
|
346
|
+
</YouVersionAuthProvider>,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
await vi.waitFor(() => {
|
|
350
|
+
expect(getByTestId('is-loading')).toHaveTextContent('false');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(getByTestId('user-info')).toHaveTextContent('null');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('should clear user when refresh token exists but no idToken after refresh', async () => {
|
|
357
|
+
YouVersionPlatformConfiguration.saveAuthData(null, 'existing-refresh-token', null, null);
|
|
358
|
+
vi.mocked(YouVersionAPIUsers.refreshTokenIfNeeded).mockResolvedValue(false);
|
|
359
|
+
|
|
360
|
+
const { getByTestId } = render(
|
|
361
|
+
<YouVersionAuthProvider config={mockConfig}>
|
|
362
|
+
<TestChild />
|
|
363
|
+
</YouVersionAuthProvider>,
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
await vi.waitFor(() => {
|
|
367
|
+
expect(getByTestId('is-loading')).toHaveTextContent('false');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
expect(getByTestId('user-info')).toHaveTextContent('null');
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState, type ReactNode } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
YouVersionAPIUsers,
|
|
6
|
+
YouVersionPlatformConfiguration,
|
|
7
|
+
type YouVersionUserInfo,
|
|
8
|
+
} from '@youversion/platform-core';
|
|
9
|
+
import { YouVersionAuthContext } from './YouVersionAuthContext';
|
|
10
|
+
import type { AuthConfig, AuthContextValue } from '../types/auth';
|
|
11
|
+
|
|
12
|
+
export interface YouVersionAuthProviderProps {
|
|
13
|
+
config: AuthConfig;
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function YouVersionAuthProvider({
|
|
18
|
+
config,
|
|
19
|
+
children,
|
|
20
|
+
}: YouVersionAuthProviderProps): React.ReactElement {
|
|
21
|
+
const [userInfo, setUserInfo] = useState<YouVersionUserInfo | null>(null);
|
|
22
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
23
|
+
const [error, setError] = useState<Error | null>(null);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
let mounted = true;
|
|
27
|
+
const initAuth = async () => {
|
|
28
|
+
// Set configuration
|
|
29
|
+
YouVersionPlatformConfiguration.appKey = config.appKey;
|
|
30
|
+
YouVersionPlatformConfiguration.apiHost = config.apiHost ?? 'api.youversion.com';
|
|
31
|
+
|
|
32
|
+
if (typeof window !== 'undefined') {
|
|
33
|
+
// Check for OAuth callback
|
|
34
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
35
|
+
const isOAuthCallback = urlParams.has('state') || urlParams.has('error');
|
|
36
|
+
|
|
37
|
+
if (isOAuthCallback) {
|
|
38
|
+
try {
|
|
39
|
+
const result = await YouVersionAPIUsers.handleAuthCallback();
|
|
40
|
+
if (result && YouVersionPlatformConfiguration.idToken) {
|
|
41
|
+
const info = YouVersionAPIUsers.userInfo(YouVersionPlatformConfiguration.idToken);
|
|
42
|
+
if (!mounted) return;
|
|
43
|
+
setUserInfo(info);
|
|
44
|
+
}
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (!mounted) return;
|
|
47
|
+
setError(err as Error);
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
// Check for existing token
|
|
51
|
+
const refreshToken = YouVersionPlatformConfiguration.refreshToken;
|
|
52
|
+
if (refreshToken) {
|
|
53
|
+
try {
|
|
54
|
+
await YouVersionAPIUsers.refreshTokenIfNeeded();
|
|
55
|
+
const idToken = YouVersionPlatformConfiguration.idToken;
|
|
56
|
+
if (idToken) {
|
|
57
|
+
const info = YouVersionAPIUsers.userInfo(idToken);
|
|
58
|
+
if (!mounted) return;
|
|
59
|
+
setUserInfo(info);
|
|
60
|
+
} else {
|
|
61
|
+
if (!mounted) return;
|
|
62
|
+
setUserInfo(null);
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
if (!mounted) return;
|
|
66
|
+
setUserInfo(null);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!mounted) return;
|
|
73
|
+
setIsLoading(false);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
void initAuth();
|
|
77
|
+
return () => {
|
|
78
|
+
mounted = false;
|
|
79
|
+
};
|
|
80
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
81
|
+
|
|
82
|
+
const value: AuthContextValue = {
|
|
83
|
+
userInfo,
|
|
84
|
+
setUserInfo,
|
|
85
|
+
isLoading,
|
|
86
|
+
error,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return <YouVersionAuthContext.Provider value={value}>{children}</YouVersionAuthContext.Provider>;
|
|
90
|
+
}
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { createContext } from 'react';
|
|
4
4
|
|
|
5
|
-
type
|
|
5
|
+
type YouVersionContextData = {
|
|
6
6
|
appKey: string;
|
|
7
7
|
apiHost?: string;
|
|
8
8
|
installationId?: string;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
export const
|
|
11
|
+
export const YouVersionContext = createContext<YouVersionContextData | null>(null);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { PropsWithChildren, ReactNode } from 'react';
|
|
4
|
+
import { lazy, Suspense, useEffect } from 'react';
|
|
5
|
+
import { YouVersionContext } from './YouVersionContext';
|
|
6
|
+
import { YouVersionPlatformConfiguration } from '@youversion/platform-core';
|
|
7
|
+
|
|
8
|
+
interface YouVersionProviderPropsBase {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
appKey: string;
|
|
11
|
+
apiHost?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface YouVersionProviderPropsWithAuth extends YouVersionProviderPropsBase {
|
|
15
|
+
authRedirectUrl: string;
|
|
16
|
+
includeAuth: true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface YouVersionProviderPropsWithoutAuth extends YouVersionProviderPropsBase {
|
|
20
|
+
includeAuth?: false;
|
|
21
|
+
authRedirectUrl?: never;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const AuthProvider = lazy(() => import('./YouVersionAuthProvider'));
|
|
25
|
+
|
|
26
|
+
export function YouVersionProvider(
|
|
27
|
+
props: PropsWithChildren<YouVersionProviderPropsWithAuth | YouVersionProviderPropsWithoutAuth>,
|
|
28
|
+
): React.ReactElement {
|
|
29
|
+
const { appKey, apiHost = 'api.youversion.com', includeAuth, children } = props;
|
|
30
|
+
|
|
31
|
+
// Syncing appKey and apiHost to YouVersionPlatformConfiguration
|
|
32
|
+
// so that this can be in sync with any other code that uses
|
|
33
|
+
// the YouVersionPlatformConfiguration, of which a lot of our
|
|
34
|
+
// core package uses this configuration.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
YouVersionPlatformConfiguration.appKey = appKey;
|
|
37
|
+
YouVersionPlatformConfiguration.apiHost = apiHost;
|
|
38
|
+
}, [appKey, apiHost]);
|
|
39
|
+
|
|
40
|
+
if (includeAuth) {
|
|
41
|
+
const { authRedirectUrl } = props;
|
|
42
|
+
|
|
43
|
+
// Installation ID gets set automatically by YouVersionPlatformConfiguration
|
|
44
|
+
return (
|
|
45
|
+
<YouVersionContext.Provider
|
|
46
|
+
value={{ appKey, apiHost, installationId: YouVersionPlatformConfiguration.installationId }}
|
|
47
|
+
>
|
|
48
|
+
<Suspense>
|
|
49
|
+
<AuthProvider config={{ appKey, apiHost, redirectUri: authRedirectUrl }}>
|
|
50
|
+
{children}
|
|
51
|
+
</AuthProvider>
|
|
52
|
+
</Suspense>
|
|
53
|
+
</YouVersionContext.Provider>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Installation ID gets set automatically by YouVersionPlatformConfiguration
|
|
58
|
+
return (
|
|
59
|
+
<YouVersionContext.Provider
|
|
60
|
+
value={{ appKey, apiHost, installationId: YouVersionPlatformConfiguration.installationId }}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
</YouVersionContext.Provider>
|
|
64
|
+
);
|
|
65
|
+
}
|
package/src/context/index.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -18,3 +18,9 @@ export * from './usePassage';
|
|
|
18
18
|
export * from './useVOTD';
|
|
19
19
|
export * from './useHighlights';
|
|
20
20
|
export * from './useLanguages';
|
|
21
|
+
|
|
22
|
+
// Auth hooks
|
|
23
|
+
export * from './useYVAuth';
|
|
24
|
+
export * from './context/YouVersionAuthContext';
|
|
25
|
+
export * from './context/YouVersionAuthProvider';
|
|
26
|
+
export * from './types/auth';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { YouVersionUserInfo } from '@youversion/platform-core';
|
|
2
|
+
import type { Dispatch, SetStateAction } from 'react';
|
|
3
|
+
|
|
4
|
+
export interface AuthConfig {
|
|
5
|
+
appKey: string;
|
|
6
|
+
apiHost?: string;
|
|
7
|
+
redirectUri?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AuthContextValue {
|
|
11
|
+
userInfo: YouVersionUserInfo | null;
|
|
12
|
+
setUserInfo: Dispatch<SetStateAction<YouVersionUserInfo | null>>;
|
|
13
|
+
isLoading: boolean;
|
|
14
|
+
error: Error | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AuthProviderProps {
|
|
18
|
+
config: AuthConfig;
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
}
|
|
@@ -2,7 +2,7 @@ import { renderHook } from '@testing-library/react';
|
|
|
2
2
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
4
|
import { useBibleClient } from './useBibleClient';
|
|
5
|
-
import {
|
|
5
|
+
import { YouVersionContext } from './context';
|
|
6
6
|
import { BibleClient, ApiClient } from '@youversion/platform-core';
|
|
7
7
|
|
|
8
8
|
vi.mock('@youversion/platform-core', async () => {
|
|
@@ -31,13 +31,13 @@ describe('useBibleClient', () => {
|
|
|
31
31
|
|
|
32
32
|
it('should create and return a BibleClient instance when context is valid', () => {
|
|
33
33
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
34
|
-
<
|
|
34
|
+
<YouVersionContext.Provider
|
|
35
35
|
value={{
|
|
36
36
|
appKey: mockAppKey,
|
|
37
37
|
}}
|
|
38
38
|
>
|
|
39
39
|
{children}
|
|
40
|
-
</
|
|
40
|
+
</YouVersionContext.Provider>
|
|
41
41
|
);
|
|
42
42
|
|
|
43
43
|
const { result } = renderHook(() => useBibleClient(), { wrapper });
|
|
@@ -51,35 +51,35 @@ describe('useBibleClient', () => {
|
|
|
51
51
|
|
|
52
52
|
it('should throw error when context is not provided', () => {
|
|
53
53
|
expect(() => renderHook(() => useBibleClient())).toThrow(
|
|
54
|
-
'
|
|
54
|
+
'YouVersion context not found. Make sure your component is wrapped with YouVersionProvider and an API key is provided.',
|
|
55
55
|
);
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
it('should throw error when appKey is missing', () => {
|
|
59
59
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
60
|
-
<
|
|
60
|
+
<YouVersionContext.Provider
|
|
61
61
|
value={{
|
|
62
62
|
appKey: '',
|
|
63
63
|
}}
|
|
64
64
|
>
|
|
65
65
|
{children}
|
|
66
|
-
</
|
|
66
|
+
</YouVersionContext.Provider>
|
|
67
67
|
);
|
|
68
68
|
|
|
69
69
|
expect(() => renderHook(() => useBibleClient(), { wrapper })).toThrow(
|
|
70
|
-
'
|
|
70
|
+
'YouVersion context not found. Make sure your component is wrapped with YouVersionProvider and an API key is provided.',
|
|
71
71
|
);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
it('should memoize the BibleClient instance', () => {
|
|
75
75
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
76
|
-
<
|
|
76
|
+
<YouVersionContext.Provider
|
|
77
77
|
value={{
|
|
78
78
|
appKey: mockAppKey,
|
|
79
79
|
}}
|
|
80
80
|
>
|
|
81
81
|
{children}
|
|
82
|
-
</
|
|
82
|
+
</YouVersionContext.Provider>
|
|
83
83
|
);
|
|
84
84
|
|
|
85
85
|
const { result, rerender } = renderHook(() => useBibleClient(), { wrapper });
|
|
@@ -96,13 +96,13 @@ describe('useBibleClient', () => {
|
|
|
96
96
|
let currentAppKey = mockAppKey;
|
|
97
97
|
|
|
98
98
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
99
|
-
<
|
|
99
|
+
<YouVersionContext.Provider
|
|
100
100
|
value={{
|
|
101
101
|
appKey: currentAppKey,
|
|
102
102
|
}}
|
|
103
103
|
>
|
|
104
104
|
{children}
|
|
105
|
-
</
|
|
105
|
+
</YouVersionContext.Provider>
|
|
106
106
|
);
|
|
107
107
|
|
|
108
108
|
const { result, rerender } = renderHook(() => useBibleClient(), { wrapper });
|
|
@@ -127,17 +127,17 @@ describe('useBibleClient', () => {
|
|
|
127
127
|
|
|
128
128
|
it('should throw error when appKey is null', () => {
|
|
129
129
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
130
|
-
<
|
|
130
|
+
<YouVersionContext.Provider
|
|
131
131
|
value={{
|
|
132
132
|
appKey: null as unknown as string,
|
|
133
133
|
}}
|
|
134
134
|
>
|
|
135
135
|
{children}
|
|
136
|
-
</
|
|
136
|
+
</YouVersionContext.Provider>
|
|
137
137
|
);
|
|
138
138
|
|
|
139
139
|
expect(() => renderHook(() => useBibleClient(), { wrapper })).toThrow(
|
|
140
|
-
'
|
|
140
|
+
'YouVersion context not found. Make sure your component is wrapped with YouVersionProvider and an API key is provided.',
|
|
141
141
|
);
|
|
142
142
|
});
|
|
143
143
|
});
|