@youversion/platform-react-hooks 1.12.2 → 1.14.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.
@@ -1,4 +1,4 @@
1
1
 
2
- > @youversion/platform-react-hooks@1.12.2 build /home/runner/work/platform-sdk-react/platform-sdk-react/packages/hooks
2
+ > @youversion/platform-react-hooks@1.14.0 build /home/runner/work/platform-sdk-react/platform-sdk-react/packages/hooks
3
3
  > tsc -p tsconfig.build.json
4
4
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @youversion/platform-react-hooks
2
2
 
3
+ ## 1.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2d2c597: Added 'system' as an option to YouVersionProvider theme prop that resolves via `prefers-color-scheme` with live OS change listener
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [2d2c597]
12
+ - @youversion/platform-core@1.14.0
13
+
14
+ ## 1.13.0
15
+
16
+ ### Minor Changes
17
+
18
+ - d5579d5: Add suggested languages to Bible version picker
19
+ - Auto-detect user's preferred language from browser settings instead of defaulting to English
20
+ - Display suggested languages based on available Bible versions and user locale
21
+ - Fetch complete language data with display names for better internationalization
22
+ - Add integration tests and Storybook stories for suggested languages functionality
23
+
24
+ ### Patch Changes
25
+
26
+ - Updated dependencies [d5579d5]
27
+ - @youversion/platform-core@1.13.0
28
+
3
29
  ## 1.12.2
4
30
 
5
31
  ### Patch Changes
@@ -3,7 +3,7 @@ interface YouVersionProviderPropsBase {
3
3
  children: ReactNode;
4
4
  appKey: string;
5
5
  apiHost?: string;
6
- theme?: 'light' | 'dark';
6
+ theme?: 'light' | 'dark' | 'system';
7
7
  }
8
8
  interface YouVersionProviderPropsWithAuth extends YouVersionProviderPropsBase {
9
9
  authRedirectUrl: string;
@@ -1 +1 @@
1
- {"version":3,"file":"YouVersionProvider.d.ts","sourceRoot":"","sources":["../../src/context/YouVersionProvider.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAK1D,UAAU,2BAA2B;IACnC,QAAQ,EAAE,SAAS,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;CAC1B;AAED,UAAU,+BAAgC,SAAQ,2BAA2B;IAC3E,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB;AAED,UAAU,kCAAmC,SAAQ,2BAA2B;IAC9E,WAAW,CAAC,EAAE,KAAK,CAAC;IACpB,eAAe,CAAC,EAAE,KAAK,CAAC;CACzB;AAID,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,iBAAiB,CAAC,+BAA+B,GAAG,kCAAkC,CAAC,GAC7F,KAAK,CAAC,YAAY,CAiDpB"}
1
+ {"version":3,"file":"YouVersionProvider.d.ts","sourceRoot":"","sources":["../../src/context/YouVersionProvider.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAK1D,UAAU,2BAA2B;IACnC,QAAQ,EAAE,SAAS,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;CACrC;AAED,UAAU,+BAAgC,SAAQ,2BAA2B;IAC3E,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB;AAED,UAAU,kCAAmC,SAAQ,2BAA2B;IAC9E,WAAW,CAAC,EAAE,KAAK,CAAC;IACpB,eAAe,CAAC,EAAE,KAAK,CAAC;CACzB;AAgCD,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,iBAAiB,CAAC,+BAA+B,GAAG,kCAAkC,CAAC,GAC7F,KAAK,CAAC,YAAY,CAkDpB"}
@@ -1,11 +1,37 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { lazy, Suspense, useEffect } from 'react';
3
+ import { lazy, Suspense, useEffect, useState } from 'react';
4
4
  import { YouVersionContext } from './YouVersionContext';
5
5
  import { YouVersionPlatformConfiguration } from '@youversion/platform-core';
6
6
  const AuthProvider = lazy(() => import('./YouVersionAuthProvider'));
7
+ function useResolvedTheme(theme) {
8
+ const [resolved, setResolved] = useState(() => {
9
+ if (theme !== 'system')
10
+ return theme;
11
+ if (typeof window === 'undefined')
12
+ return 'light';
13
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
14
+ });
15
+ useEffect(() => {
16
+ if (theme !== 'system') {
17
+ setResolved(theme);
18
+ return;
19
+ }
20
+ if (typeof window === 'undefined')
21
+ return;
22
+ const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
23
+ setResolved(mediaQueryList.matches ? 'dark' : 'light');
24
+ const handler = (e) => {
25
+ setResolved(e.matches ? 'dark' : 'light');
26
+ };
27
+ mediaQueryList.addEventListener('change', handler);
28
+ return () => mediaQueryList.removeEventListener('change', handler);
29
+ }, [theme]);
30
+ return resolved;
31
+ }
7
32
  export function YouVersionProvider(props) {
8
33
  const { appKey, apiHost = 'api.youversion.com', includeAuth, theme = 'light', children } = props;
34
+ const resolvedTheme = useResolvedTheme(theme);
9
35
  // Syncing appKey and apiHost to YouVersionPlatformConfiguration
10
36
  // so that this can be in sync with any other code that uses
11
37
  // the YouVersionPlatformConfiguration, of which a lot of our
@@ -21,7 +47,7 @@ export function YouVersionProvider(props) {
21
47
  appKey,
22
48
  apiHost,
23
49
  installationId: YouVersionPlatformConfiguration.installationId,
24
- theme,
50
+ theme: resolvedTheme,
25
51
  authEnabled: !!includeAuth,
26
52
  }, children: _jsx(Suspense, { children: _jsx(AuthProvider, { config: { appKey, apiHost, redirectUri: authRedirectUrl }, children: children }) }) }));
27
53
  }
@@ -30,7 +56,7 @@ export function YouVersionProvider(props) {
30
56
  appKey,
31
57
  apiHost,
32
58
  installationId: YouVersionPlatformConfiguration.installationId,
33
- theme,
59
+ theme: resolvedTheme,
34
60
  authEnabled: !!includeAuth,
35
61
  }, children: children }));
36
62
  }
@@ -1 +1 @@
1
- {"version":3,"file":"YouVersionProvider.js","sourceRoot":"","sources":["../../src/context/YouVersionProvider.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AAGb,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAClD,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,EAAE,+BAA+B,EAAE,MAAM,2BAA2B,CAAC;AAmB5E,MAAM,YAAY,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,0BAA0B,CAAC,CAAC,CAAC;AAEpE,MAAM,UAAU,kBAAkB,CAChC,KAA8F;IAE9F,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,oBAAoB,EAAE,WAAW,EAAE,KAAK,GAAG,OAAO,EAAE,QAAQ,EAAE,GAAG,KAAK,CAAC;IAEjG,gEAAgE;IAChE,4DAA4D;IAC5D,6DAA6D;IAC7D,wCAAwC;IACxC,SAAS,CAAC,GAAG,EAAE;QACb,+BAA+B,CAAC,MAAM,GAAG,MAAM,CAAC;QAChD,+BAA+B,CAAC,OAAO,GAAG,OAAO,CAAC;IACpD,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAEtB,IAAI,WAAW,EAAE,CAAC;QAChB,MAAM,EAAE,eAAe,EAAE,GAAG,KAAK,CAAC;QAElC,4EAA4E;QAC5E,OAAO,CACL,KAAC,iBAAiB,CAAC,QAAQ,IACzB,KAAK,EAAE;gBACL,MAAM;gBACN,OAAO;gBACP,cAAc,EAAE,+BAA+B,CAAC,cAAc;gBAC9D,KAAK;gBACL,WAAW,EAAE,CAAC,CAAC,WAAW;aAC3B,YAED,KAAC,QAAQ,cACP,KAAC,YAAY,IAAC,MAAM,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,YACpE,QAAQ,GACI,GACN,GACgB,CAC9B,CAAC;IACJ,CAAC;IAED,4EAA4E;IAC5E,OAAO,CACL,KAAC,iBAAiB,CAAC,QAAQ,IACzB,KAAK,EAAE;YACL,MAAM;YACN,OAAO;YACP,cAAc,EAAE,+BAA+B,CAAC,cAAc;YAC9D,KAAK;YACL,WAAW,EAAE,CAAC,CAAC,WAAW;SAC3B,YAEA,QAAQ,GACkB,CAC9B,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"YouVersionProvider.js","sourceRoot":"","sources":["../../src/context/YouVersionProvider.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youversion/platform-react-hooks",
3
- "version": "1.12.2",
3
+ "version": "1.14.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -22,7 +22,7 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
25
- "@youversion/platform-core": "1.12.2"
25
+ "@youversion/platform-core": "1.14.0"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "react": ">=19.1.0 <20.0.0"
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import type { PropsWithChildren, ReactNode } from 'react';
4
- import { lazy, Suspense, useEffect } from 'react';
4
+ import { lazy, Suspense, useEffect, useState } from 'react';
5
5
  import { YouVersionContext } from './YouVersionContext';
6
6
  import { YouVersionPlatformConfiguration } from '@youversion/platform-core';
7
7
 
@@ -9,7 +9,7 @@ interface YouVersionProviderPropsBase {
9
9
  children: ReactNode;
10
10
  appKey: string;
11
11
  apiHost?: string;
12
- theme?: 'light' | 'dark';
12
+ theme?: 'light' | 'dark' | 'system';
13
13
  }
14
14
 
15
15
  interface YouVersionProviderPropsWithAuth extends YouVersionProviderPropsBase {
@@ -24,10 +24,39 @@ interface YouVersionProviderPropsWithoutAuth extends YouVersionProviderPropsBase
24
24
 
25
25
  const AuthProvider = lazy(() => import('./YouVersionAuthProvider'));
26
26
 
27
+ function useResolvedTheme(theme: 'light' | 'dark' | 'system'): 'light' | 'dark' {
28
+ const [resolved, setResolved] = useState<'light' | 'dark'>(() => {
29
+ if (theme !== 'system') return theme;
30
+ if (typeof window === 'undefined') return 'light';
31
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
32
+ });
33
+
34
+ useEffect(() => {
35
+ if (theme !== 'system') {
36
+ setResolved(theme);
37
+ return;
38
+ }
39
+
40
+ if (typeof window === 'undefined') return;
41
+
42
+ const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
43
+ setResolved(mediaQueryList.matches ? 'dark' : 'light');
44
+
45
+ const handler = (e: MediaQueryListEvent) => {
46
+ setResolved(e.matches ? 'dark' : 'light');
47
+ };
48
+ mediaQueryList.addEventListener('change', handler);
49
+ return () => mediaQueryList.removeEventListener('change', handler);
50
+ }, [theme]);
51
+
52
+ return resolved;
53
+ }
54
+
27
55
  export function YouVersionProvider(
28
56
  props: PropsWithChildren<YouVersionProviderPropsWithAuth | YouVersionProviderPropsWithoutAuth>,
29
57
  ): React.ReactElement {
30
58
  const { appKey, apiHost = 'api.youversion.com', includeAuth, theme = 'light', children } = props;
59
+ const resolvedTheme = useResolvedTheme(theme);
31
60
 
32
61
  // Syncing appKey and apiHost to YouVersionPlatformConfiguration
33
62
  // so that this can be in sync with any other code that uses
@@ -48,7 +77,7 @@ export function YouVersionProvider(
48
77
  appKey,
49
78
  apiHost,
50
79
  installationId: YouVersionPlatformConfiguration.installationId,
51
- theme,
80
+ theme: resolvedTheme,
52
81
  authEnabled: !!includeAuth,
53
82
  }}
54
83
  >
@@ -68,7 +97,7 @@ export function YouVersionProvider(
68
97
  appKey,
69
98
  apiHost,
70
99
  installationId: YouVersionPlatformConfiguration.installationId,
71
- theme,
100
+ theme: resolvedTheme,
72
101
  authEnabled: !!includeAuth,
73
102
  }}
74
103
  >
@@ -0,0 +1,228 @@
1
+ import { renderHook, waitFor, act } from '@testing-library/react';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import type { ReactNode } from 'react';
4
+ import { useChapter } from './useChapter';
5
+ import { YouVersionContext } from './context';
6
+ import { type BibleClient, type BibleChapter } from '@youversion/platform-core';
7
+ import { useBibleClient } from './useBibleClient';
8
+
9
+ vi.mock('./useBibleClient');
10
+
11
+ describe('useChapter', () => {
12
+ const mockAppKey = 'test-app-key';
13
+ const mockGetChapter = vi.fn();
14
+
15
+ const mockChapter: BibleChapter = {
16
+ id: '1',
17
+ passage_id: 'MAT.1',
18
+ title: 'Matthew 1',
19
+ };
20
+
21
+ const createWrapper = (contextValue: { appKey: string }) => {
22
+ return ({ children }: { children: ReactNode }) => (
23
+ <YouVersionContext.Provider value={contextValue}>{children}</YouVersionContext.Provider>
24
+ );
25
+ };
26
+
27
+ beforeEach(() => {
28
+ vi.resetAllMocks();
29
+
30
+ mockGetChapter.mockResolvedValue(mockChapter);
31
+
32
+ const mockClient: Partial<BibleClient> = { getChapter: mockGetChapter };
33
+ vi.mocked(useBibleClient).mockReturnValue(mockClient as BibleClient);
34
+ });
35
+
36
+ describe('fetching chapter', () => {
37
+ it('should fetch chapter with versionId, book, chapter params', async () => {
38
+ const wrapper = createWrapper({
39
+ appKey: mockAppKey,
40
+ });
41
+
42
+ const { result } = renderHook(() => useChapter(111, 'MAT', 1), { wrapper });
43
+
44
+ expect(result.current.loading).toBe(true);
45
+ expect(result.current.chapter).toBe(null);
46
+
47
+ await waitFor(() => {
48
+ expect(result.current.loading).toBe(false);
49
+ });
50
+
51
+ expect(mockGetChapter).toHaveBeenCalledWith(111, 'MAT', 1);
52
+ expect(result.current.chapter).toEqual(mockChapter);
53
+ });
54
+
55
+ it('should refetch when versionId changes', async () => {
56
+ const wrapper = createWrapper({
57
+ appKey: mockAppKey,
58
+ });
59
+
60
+ const { result, rerender } = renderHook(({ versionId }) => useChapter(versionId, 'MAT', 1), {
61
+ wrapper,
62
+ initialProps: { versionId: 1 },
63
+ });
64
+
65
+ await waitFor(() => {
66
+ expect(result.current.loading).toBe(false);
67
+ });
68
+
69
+ expect(mockGetChapter).toHaveBeenCalledTimes(1);
70
+ expect(mockGetChapter).toHaveBeenLastCalledWith(1, 'MAT', 1);
71
+
72
+ act(() => {
73
+ rerender({ versionId: 111 });
74
+ });
75
+
76
+ await waitFor(() => {
77
+ expect(result.current.loading).toBe(false);
78
+ });
79
+
80
+ expect(mockGetChapter).toHaveBeenCalledTimes(2);
81
+ expect(mockGetChapter).toHaveBeenLastCalledWith(111, 'MAT', 1);
82
+ });
83
+
84
+ it('should refetch when book changes', async () => {
85
+ const wrapper = createWrapper({
86
+ appKey: mockAppKey,
87
+ });
88
+
89
+ const { result, rerender } = renderHook(({ book }) => useChapter(1, book, 1), {
90
+ wrapper,
91
+ initialProps: { book: 'MAT' },
92
+ });
93
+
94
+ await waitFor(() => {
95
+ expect(result.current.loading).toBe(false);
96
+ });
97
+
98
+ expect(mockGetChapter).toHaveBeenCalledTimes(1);
99
+ expect(mockGetChapter).toHaveBeenLastCalledWith(1, 'MAT', 1);
100
+
101
+ act(() => {
102
+ rerender({ book: 'GEN' });
103
+ });
104
+
105
+ await waitFor(() => {
106
+ expect(result.current.loading).toBe(false);
107
+ });
108
+
109
+ expect(mockGetChapter).toHaveBeenCalledTimes(2);
110
+ expect(mockGetChapter).toHaveBeenLastCalledWith(1, 'GEN', 1);
111
+ });
112
+
113
+ it('should refetch when chapter changes', async () => {
114
+ const wrapper = createWrapper({
115
+ appKey: mockAppKey,
116
+ });
117
+
118
+ const { result, rerender } = renderHook(({ chapter }) => useChapter(1, 'MAT', chapter), {
119
+ wrapper,
120
+ initialProps: { chapter: 1 },
121
+ });
122
+
123
+ await waitFor(() => {
124
+ expect(result.current.loading).toBe(false);
125
+ });
126
+
127
+ expect(mockGetChapter).toHaveBeenCalledTimes(1);
128
+ expect(mockGetChapter).toHaveBeenLastCalledWith(1, 'MAT', 1);
129
+
130
+ act(() => {
131
+ rerender({ chapter: 5 });
132
+ });
133
+
134
+ await waitFor(() => {
135
+ expect(result.current.loading).toBe(false);
136
+ });
137
+
138
+ expect(mockGetChapter).toHaveBeenCalledTimes(2);
139
+ expect(mockGetChapter).toHaveBeenLastCalledWith(1, 'MAT', 5);
140
+ });
141
+
142
+ it('should not fetch when enabled is false', async () => {
143
+ const wrapper = createWrapper({
144
+ appKey: mockAppKey,
145
+ });
146
+
147
+ const { result } = renderHook(() => useChapter(1, 'MAT', 1, { enabled: false }), {
148
+ wrapper,
149
+ });
150
+
151
+ await waitFor(() => {
152
+ expect(result.current.loading).toBe(false);
153
+ });
154
+
155
+ expect(mockGetChapter).not.toHaveBeenCalled();
156
+ expect(result.current.chapter).toBe(null);
157
+ });
158
+
159
+ it('should handle fetch errors', async () => {
160
+ const error = new Error('Failed to fetch chapter');
161
+ mockGetChapter.mockRejectedValueOnce(error);
162
+
163
+ const wrapper = createWrapper({
164
+ appKey: mockAppKey,
165
+ });
166
+
167
+ const { result } = renderHook(() => useChapter(1, 'MAT', 1), { wrapper });
168
+
169
+ await waitFor(() => {
170
+ expect(result.current.loading).toBe(false);
171
+ });
172
+
173
+ expect(result.current.error).toEqual(error);
174
+ expect(result.current.chapter).toBe(null);
175
+ });
176
+
177
+ it('should clear error on successful refetch', async () => {
178
+ const error = new Error('Failed to fetch chapter');
179
+ mockGetChapter.mockRejectedValueOnce(error).mockResolvedValueOnce(mockChapter);
180
+
181
+ const wrapper = createWrapper({
182
+ appKey: mockAppKey,
183
+ });
184
+
185
+ const { result } = renderHook(() => useChapter(1, 'MAT', 1), { wrapper });
186
+
187
+ await waitFor(() => {
188
+ expect(result.current.loading).toBe(false);
189
+ });
190
+
191
+ expect(result.current.error).toEqual(error);
192
+ expect(result.current.chapter).toBe(null);
193
+
194
+ act(() => {
195
+ result.current.refetch();
196
+ });
197
+
198
+ await waitFor(() => {
199
+ expect(result.current.loading).toBe(false);
200
+ });
201
+
202
+ expect(result.current.error).toBe(null);
203
+ expect(result.current.chapter).toEqual(mockChapter);
204
+ });
205
+
206
+ it('should support manual refetch', async () => {
207
+ const wrapper = createWrapper({
208
+ appKey: mockAppKey,
209
+ });
210
+
211
+ const { result } = renderHook(() => useChapter(1, 'MAT', 1), { wrapper });
212
+
213
+ await waitFor(() => {
214
+ expect(result.current.loading).toBe(false);
215
+ });
216
+
217
+ expect(mockGetChapter).toHaveBeenCalledTimes(1);
218
+
219
+ act(() => {
220
+ result.current.refetch();
221
+ });
222
+
223
+ await waitFor(() => {
224
+ expect(mockGetChapter).toHaveBeenCalledTimes(2);
225
+ });
226
+ });
227
+ });
228
+ });
@@ -0,0 +1,278 @@
1
+ import { renderHook, waitFor, act } from '@testing-library/react';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import type { ReactNode } from 'react';
4
+ import { useChapters } from './useChapters';
5
+ import { YouVersionContext } from './context';
6
+ import { type BibleClient, type BibleChapter, type Collection } from '@youversion/platform-core';
7
+ import { useBibleClient } from './useBibleClient';
8
+
9
+ vi.mock('./useBibleClient');
10
+
11
+ describe('useChapters', () => {
12
+ const mockAppKey = 'test-app-key';
13
+ const mockGetChapters = vi.fn();
14
+
15
+ const mockChapters: Collection<BibleChapter> = {
16
+ data: [
17
+ { id: '1', passage_id: 'MAT.1.1', title: 'Matthew 1' },
18
+ { id: '2', passage_id: 'MAT.1.2', title: 'Matthew 2' },
19
+ { id: '3', passage_id: 'MAT.1.3', title: 'Matthew 3' },
20
+ ],
21
+ next_page_token: null,
22
+ };
23
+
24
+ const createWrapper = (contextValue: { appKey: string }) => {
25
+ return ({ children }: { children: ReactNode }) => (
26
+ <YouVersionContext.Provider value={contextValue}>{children}</YouVersionContext.Provider>
27
+ );
28
+ };
29
+
30
+ beforeEach(() => {
31
+ vi.resetAllMocks();
32
+
33
+ mockGetChapters.mockResolvedValue(mockChapters);
34
+
35
+ const mockClient: Partial<BibleClient> = { getChapters: mockGetChapters };
36
+ vi.mocked(useBibleClient).mockReturnValue(mockClient as BibleClient);
37
+ });
38
+
39
+ describe('fetching chapters', () => {
40
+ it('should fetch chapters with versionId, book params', async () => {
41
+ const wrapper = createWrapper({
42
+ appKey: mockAppKey,
43
+ });
44
+
45
+ const { result } = renderHook(() => useChapters(111, 'MAT'), { wrapper });
46
+
47
+ expect(result.current.loading).toBe(true);
48
+ expect(result.current.chapters).toBe(null);
49
+
50
+ await waitFor(() => {
51
+ expect(result.current.loading).toBe(false);
52
+ });
53
+
54
+ expect(mockGetChapters).toHaveBeenCalledWith(111, 'MAT');
55
+ expect(result.current.chapters).toEqual(mockChapters);
56
+ });
57
+
58
+ it('should refetch when versionId changes', async () => {
59
+ const wrapper = createWrapper({
60
+ appKey: mockAppKey,
61
+ });
62
+
63
+ const { result, rerender } = renderHook(({ versionId }) => useChapters(versionId, 'MAT'), {
64
+ wrapper,
65
+ initialProps: { versionId: 1 },
66
+ });
67
+
68
+ await waitFor(() => {
69
+ expect(result.current.loading).toBe(false);
70
+ });
71
+
72
+ expect(mockGetChapters).toHaveBeenCalledTimes(1);
73
+ expect(mockGetChapters).toHaveBeenLastCalledWith(1, 'MAT');
74
+
75
+ act(() => {
76
+ rerender({ versionId: 111 });
77
+ });
78
+
79
+ await waitFor(() => {
80
+ expect(result.current.loading).toBe(false);
81
+ });
82
+
83
+ expect(mockGetChapters).toHaveBeenCalledTimes(2);
84
+ expect(mockGetChapters).toHaveBeenLastCalledWith(111, 'MAT');
85
+ });
86
+
87
+ it('should refetch when book changes', async () => {
88
+ const wrapper = createWrapper({
89
+ appKey: mockAppKey,
90
+ });
91
+
92
+ const { result, rerender } = renderHook(({ book }) => useChapters(1, book), {
93
+ wrapper,
94
+ initialProps: { book: 'MAT' },
95
+ });
96
+
97
+ await waitFor(() => {
98
+ expect(result.current.loading).toBe(false);
99
+ });
100
+
101
+ expect(mockGetChapters).toHaveBeenCalledTimes(1);
102
+ expect(mockGetChapters).toHaveBeenLastCalledWith(1, 'MAT');
103
+
104
+ act(() => {
105
+ rerender({ book: 'GEN' });
106
+ });
107
+
108
+ await waitFor(() => {
109
+ expect(result.current.loading).toBe(false);
110
+ });
111
+
112
+ expect(mockGetChapters).toHaveBeenCalledTimes(2);
113
+ expect(mockGetChapters).toHaveBeenLastCalledWith(1, 'GEN');
114
+ });
115
+
116
+ it('should not fetch when enabled is false', async () => {
117
+ const wrapper = createWrapper({
118
+ appKey: mockAppKey,
119
+ });
120
+
121
+ const { result } = renderHook(() => useChapters(1, 'MAT', { enabled: false }), {
122
+ wrapper,
123
+ });
124
+
125
+ await waitFor(() => {
126
+ expect(result.current.loading).toBe(false);
127
+ });
128
+
129
+ expect(mockGetChapters).not.toHaveBeenCalled();
130
+ expect(result.current.chapters).toBe(null);
131
+ });
132
+
133
+ it('should handle fetch errors', async () => {
134
+ const error = new Error('Failed to fetch chapters');
135
+ mockGetChapters.mockRejectedValueOnce(error);
136
+
137
+ const wrapper = createWrapper({
138
+ appKey: mockAppKey,
139
+ });
140
+
141
+ const { result } = renderHook(() => useChapters(1, 'MAT'), { wrapper });
142
+
143
+ await waitFor(() => {
144
+ expect(result.current.loading).toBe(false);
145
+ });
146
+
147
+ expect(result.current.error).toEqual(error);
148
+ expect(result.current.chapters).toBe(null);
149
+ });
150
+
151
+ it('should clear error on successful refetch', async () => {
152
+ const error = new Error('Failed to fetch chapters');
153
+ mockGetChapters.mockRejectedValueOnce(error).mockResolvedValueOnce(mockChapters);
154
+
155
+ const wrapper = createWrapper({
156
+ appKey: mockAppKey,
157
+ });
158
+
159
+ const { result } = renderHook(() => useChapters(1, 'MAT'), { wrapper });
160
+
161
+ await waitFor(() => {
162
+ expect(result.current.loading).toBe(false);
163
+ });
164
+
165
+ expect(result.current.error).toEqual(error);
166
+ expect(result.current.chapters).toBe(null);
167
+
168
+ act(() => {
169
+ result.current.refetch();
170
+ });
171
+
172
+ await waitFor(() => {
173
+ expect(result.current.loading).toBe(false);
174
+ });
175
+
176
+ expect(result.current.error).toBe(null);
177
+ expect(result.current.chapters).toEqual(mockChapters);
178
+ });
179
+
180
+ it('should support manual refetch', async () => {
181
+ const wrapper = createWrapper({
182
+ appKey: mockAppKey,
183
+ });
184
+
185
+ const { result } = renderHook(() => useChapters(1, 'MAT'), { wrapper });
186
+
187
+ await waitFor(() => {
188
+ expect(result.current.loading).toBe(false);
189
+ });
190
+
191
+ expect(mockGetChapters).toHaveBeenCalledTimes(1);
192
+
193
+ act(() => {
194
+ result.current.refetch();
195
+ });
196
+
197
+ await waitFor(() => {
198
+ expect(mockGetChapters).toHaveBeenCalledTimes(2);
199
+ });
200
+ });
201
+ });
202
+
203
+ describe('book validation', () => {
204
+ it('should skip fetch when book is "undefined" string', async () => {
205
+ const wrapper = createWrapper({
206
+ appKey: mockAppKey,
207
+ });
208
+
209
+ const { result } = renderHook(() => useChapters(1, 'undefined'), { wrapper });
210
+
211
+ await waitFor(() => {
212
+ expect(result.current.loading).toBe(false);
213
+ });
214
+
215
+ expect(mockGetChapters).not.toHaveBeenCalled();
216
+ expect(result.current.chapters).toBe(null);
217
+ });
218
+
219
+ it('should skip fetch when book is "null" string', async () => {
220
+ const wrapper = createWrapper({
221
+ appKey: mockAppKey,
222
+ });
223
+
224
+ const { result } = renderHook(() => useChapters(1, 'null'), { wrapper });
225
+
226
+ await waitFor(() => {
227
+ expect(result.current.loading).toBe(false);
228
+ });
229
+
230
+ expect(mockGetChapters).not.toHaveBeenCalled();
231
+ expect(result.current.chapters).toBe(null);
232
+ });
233
+
234
+ it('should skip fetch when book is empty string', async () => {
235
+ const wrapper = createWrapper({
236
+ appKey: mockAppKey,
237
+ });
238
+
239
+ const { result } = renderHook(() => useChapters(1, ''), { wrapper });
240
+
241
+ await waitFor(() => {
242
+ expect(result.current.loading).toBe(false);
243
+ });
244
+
245
+ expect(mockGetChapters).not.toHaveBeenCalled();
246
+ expect(result.current.chapters).toBe(null);
247
+ });
248
+
249
+ it('should fetch when book changes from invalid to valid', async () => {
250
+ const wrapper = createWrapper({
251
+ appKey: mockAppKey,
252
+ });
253
+
254
+ const { result, rerender } = renderHook(({ book }) => useChapters(1, book), {
255
+ wrapper,
256
+ initialProps: { book: 'undefined' },
257
+ });
258
+
259
+ await waitFor(() => {
260
+ expect(result.current.loading).toBe(false);
261
+ });
262
+
263
+ expect(mockGetChapters).not.toHaveBeenCalled();
264
+
265
+ act(() => {
266
+ rerender({ book: 'MAT' });
267
+ });
268
+
269
+ await waitFor(() => {
270
+ expect(result.current.loading).toBe(false);
271
+ });
272
+
273
+ expect(mockGetChapters).toHaveBeenCalledTimes(1);
274
+ expect(mockGetChapters).toHaveBeenCalledWith(1, 'MAT');
275
+ expect(result.current.chapters).toEqual(mockChapters);
276
+ });
277
+ });
278
+ });
@@ -206,9 +206,11 @@ describe('useVersion', () => {
206
206
  });
207
207
 
208
208
  await waitFor(() => {
209
+ expect(result.current.loading).toBe(false);
209
210
  expect(result.current.error).toBe(null);
210
211
  });
211
212
 
213
+ expect(result.current.error).toBe(null);
212
214
  expect(result.current.version).toEqual(mockVersion);
213
215
  });
214
216
  });
@@ -605,9 +605,11 @@ describe('useVersions', () => {
605
605
  });
606
606
 
607
607
  await waitFor(() => {
608
+ expect(result.current.loading).toBe(false);
608
609
  expect(result.current.error).toBe(null);
609
610
  });
610
611
 
612
+ expect(result.current.error).toBe(null);
611
613
  expect(result.current.versions).toEqual(mockVersions);
612
614
  });
613
615
  });