@terreno/rtk 0.23.1 → 0.25.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.
Files changed (43) hide show
  1. package/dist/authSlice.d.ts.map +1 -1
  2. package/dist/authSlice.js +9 -16
  3. package/dist/authSlice.js.map +1 -1
  4. package/dist/betterAuthSlice.d.ts +9 -5
  5. package/dist/betterAuthSlice.d.ts.map +1 -1
  6. package/dist/betterAuthSlice.js +7 -20
  7. package/dist/betterAuthSlice.js.map +1 -1
  8. package/dist/isolated/constants.test.d.ts +2 -0
  9. package/dist/isolated/constants.test.d.ts.map +1 -0
  10. package/dist/isolated/{constants.isolated.js → constants.test.js} +1 -1
  11. package/dist/isolated/constants.test.js.map +1 -0
  12. package/dist/useFeatureFlags.d.ts.map +1 -1
  13. package/dist/useFeatureFlags.js +4 -3
  14. package/dist/useFeatureFlags.js.map +1 -1
  15. package/dist/useRealtimeDebug.test.d.ts +2 -0
  16. package/dist/useRealtimeDebug.test.d.ts.map +1 -0
  17. package/dist/useRealtimeDebug.test.js +131 -0
  18. package/dist/useRealtimeDebug.test.js.map +1 -0
  19. package/dist/useServerStatus.test.js +49 -0
  20. package/dist/useServerStatus.test.js.map +1 -1
  21. package/dist/useTerrenoFeatureFlags.test.js +27 -0
  22. package/dist/useTerrenoFeatureFlags.test.js.map +1 -1
  23. package/dist/useUpgradeCheck.test.d.ts +2 -0
  24. package/dist/useUpgradeCheck.test.d.ts.map +1 -0
  25. package/dist/useUpgradeCheck.test.js +174 -0
  26. package/dist/useUpgradeCheck.test.js.map +1 -0
  27. package/dist/useUpgradeCheckNative.test.d.ts +2 -0
  28. package/dist/useUpgradeCheckNative.test.d.ts.map +1 -0
  29. package/dist/useUpgradeCheckNative.test.js +90 -0
  30. package/dist/useUpgradeCheckNative.test.js.map +1 -0
  31. package/package.json +2 -2
  32. package/src/authSlice.ts +28 -14
  33. package/src/betterAuthSlice.ts +19 -18
  34. package/src/useFeatureFlags.ts +4 -3
  35. package/src/useRealtimeDebug.test.ts +173 -0
  36. package/src/useServerStatus.test.ts +68 -0
  37. package/src/useTerrenoFeatureFlags.test.ts +40 -0
  38. package/src/useUpgradeCheck.test.ts +226 -0
  39. package/src/useUpgradeCheckNative.test.ts +124 -0
  40. package/dist/isolated/constants.isolated.d.ts +0 -2
  41. package/dist/isolated/constants.isolated.d.ts.map +0 -1
  42. package/dist/isolated/constants.isolated.js.map +0 -1
  43. /package/src/isolated/{constants.isolated.ts → constants.test.ts} +0 -0
@@ -0,0 +1,173 @@
1
+ import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
2
+ import {configureStore} from "@reduxjs/toolkit";
3
+ import {act, renderHook} from "@testing-library/react-native";
4
+ import React from "react";
5
+ import {Provider} from "react-redux";
6
+
7
+ import {isWebsocketsDebugEnabled, setRealtimeDebug} from "./constants";
8
+ import {offlineReducer, setOnlineStatus} from "./offlineSlice";
9
+ import {useRealtimeDebug} from "./useRealtimeDebug";
10
+
11
+ const createTestStore = (): ReturnType<typeof configureStore> =>
12
+ configureStore({
13
+ reducer: {offline: offlineReducer},
14
+ });
15
+
16
+ const createWrapper = (
17
+ store: ReturnType<typeof createTestStore>
18
+ ): React.FC<{children: React.ReactNode}> => {
19
+ const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) =>
20
+ React.createElement(Provider, {children, store});
21
+ return Wrapper;
22
+ };
23
+
24
+ const flush = async (ms = 50): Promise<void> => {
25
+ await act(async () => {
26
+ await new Promise((resolve) => setTimeout(resolve, ms));
27
+ });
28
+ };
29
+
30
+ describe("useRealtimeDebug", () => {
31
+ let store: ReturnType<typeof createTestStore>;
32
+ let originalFetch: typeof globalThis.fetch;
33
+
34
+ beforeEach(() => {
35
+ store = createTestStore();
36
+ originalFetch = globalThis.fetch;
37
+ });
38
+
39
+ afterEach(() => {
40
+ globalThis.fetch = originalFetch;
41
+ // Reset the module-level runtime debug flag so tests do not leak into each other.
42
+ setRealtimeDebug(false);
43
+ });
44
+
45
+ it("does not fetch when offline and returns the env-based debug value", async () => {
46
+ store.dispatch(setOnlineStatus(false));
47
+ const fetchFn = mock(async () => new Response("{}", {status: 200}));
48
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
49
+
50
+ const {result, unmount} = renderHook(() => useRealtimeDebug("http://localhost:4000"), {
51
+ wrapper: createWrapper(store),
52
+ });
53
+
54
+ await flush();
55
+
56
+ expect(fetchFn).not.toHaveBeenCalled();
57
+ expect(result.current).toBe(false);
58
+ unmount();
59
+ });
60
+
61
+ it("enables debug when the health endpoint reports debug true", async () => {
62
+ const fetchFn = mock(async () => new Response(JSON.stringify({debug: true}), {status: 200}));
63
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
64
+
65
+ const {result, unmount} = renderHook(() => useRealtimeDebug("http://localhost:4000"), {
66
+ wrapper: createWrapper(store),
67
+ });
68
+
69
+ await flush();
70
+
71
+ expect(fetchFn).toHaveBeenCalled();
72
+ const callUrl = (fetchFn.mock.calls[0] as unknown[])[0] as string;
73
+ expect(callUrl).toBe("http://localhost:4000/realtime/health");
74
+ expect(isWebsocketsDebugEnabled()).toBe(true);
75
+ expect(result.current).toBe(true);
76
+ unmount();
77
+ });
78
+
79
+ it("keeps debug disabled when the health endpoint reports debug false", async () => {
80
+ const fetchFn = mock(async () => new Response(JSON.stringify({debug: false}), {status: 200}));
81
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
82
+
83
+ const {result, unmount} = renderHook(() => useRealtimeDebug("http://localhost:4000"), {
84
+ wrapper: createWrapper(store),
85
+ });
86
+
87
+ await flush();
88
+
89
+ expect(fetchFn).toHaveBeenCalled();
90
+ expect(isWebsocketsDebugEnabled()).toBe(false);
91
+ expect(result.current).toBe(false);
92
+ unmount();
93
+ });
94
+
95
+ it("ignores a non-ok health response", async () => {
96
+ const fetchFn = mock(async () => new Response("error", {status: 500}));
97
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
98
+
99
+ const {result, unmount} = renderHook(() => useRealtimeDebug("http://localhost:4000"), {
100
+ wrapper: createWrapper(store),
101
+ });
102
+
103
+ await flush();
104
+
105
+ expect(fetchFn).toHaveBeenCalled();
106
+ expect(result.current).toBe(false);
107
+ unmount();
108
+ });
109
+
110
+ it("swallows fetch errors and keeps env-based debug", async () => {
111
+ const fetchFn = mock(async () => {
112
+ throw new Error("Network error");
113
+ });
114
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
115
+
116
+ const {result, unmount} = renderHook(() => useRealtimeDebug("http://localhost:4000"), {
117
+ wrapper: createWrapper(store),
118
+ });
119
+
120
+ await flush();
121
+
122
+ expect(fetchFn).toHaveBeenCalled();
123
+ expect(result.current).toBe(false);
124
+ unmount();
125
+ });
126
+
127
+ it("does not apply the debug flag when the hook unmounts before the response resolves", async () => {
128
+ let resolveJson: (value: {debug: boolean}) => void = () => {};
129
+ const jsonPromise = new Promise<{debug: boolean}>((resolve) => {
130
+ resolveJson = resolve;
131
+ });
132
+ const fetchFn = mock(async () => ({json: () => jsonPromise, ok: true}));
133
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
134
+
135
+ const {unmount} = renderHook(() => useRealtimeDebug("http://localhost:4000"), {
136
+ wrapper: createWrapper(store),
137
+ });
138
+
139
+ // Unmount while the response body is still pending, then resolve it.
140
+ unmount();
141
+ await act(async () => {
142
+ resolveJson({debug: true});
143
+ await jsonPromise;
144
+ await new Promise((resolve) => setTimeout(resolve, 10));
145
+ });
146
+
147
+ // The cancelled guard should prevent setRealtimeDebug from running.
148
+ expect(isWebsocketsDebugEnabled()).toBe(false);
149
+ });
150
+
151
+ it("re-runs the health check when the refresh key changes", async () => {
152
+ const fetchFn = mock(async () => new Response(JSON.stringify({debug: false}), {status: 200}));
153
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
154
+
155
+ const {rerender, unmount} = renderHook(
156
+ ({key}: {key: number}) => useRealtimeDebug("http://localhost:4000", key),
157
+ {
158
+ initialProps: {key: 1},
159
+ wrapper: createWrapper(store),
160
+ }
161
+ );
162
+
163
+ await flush();
164
+ const firstCallCount = fetchFn.mock.calls.length;
165
+ expect(firstCallCount).toBeGreaterThan(0);
166
+
167
+ rerender({key: 2});
168
+ await flush();
169
+
170
+ expect(fetchFn.mock.calls.length).toBeGreaterThan(firstCallCount);
171
+ unmount();
172
+ });
173
+ });
@@ -235,6 +235,74 @@ describe("useServerStatus", () => {
235
235
  unmount();
236
236
  });
237
237
 
238
+ it("polls again after the online interval elapses", async () => {
239
+ const fetchFn = mock(async () => new Response("ok", {status: 200}));
240
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
241
+
242
+ const {unmount} = renderHook(
243
+ () =>
244
+ useServerStatus({
245
+ healthUrl: "http://localhost:4000/health",
246
+ pollIntervalMs: 20,
247
+ }),
248
+ {wrapper: createWrapper(store)}
249
+ );
250
+
251
+ // Wait long enough for several interval ticks to fire the setInterval callback.
252
+ await act(async () => {
253
+ await new Promise((resolve) => setTimeout(resolve, 120));
254
+ });
255
+
256
+ expect(fetchFn.mock.calls.length).toBeGreaterThan(1);
257
+ unmount();
258
+ });
259
+
260
+ it("aborts the in-flight health check once the request timeout fires", async () => {
261
+ const originalSetTimeout = globalThis.setTimeout;
262
+ // Fire the 4000ms abort timer synchronously so controller.abort() runs.
263
+ globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
264
+ if (timeout === 4_000 && typeof handler === "function") {
265
+ (handler as () => void)();
266
+ return 0;
267
+ }
268
+ return originalSetTimeout(handler, timeout as number, ...(args as []));
269
+ }) as unknown as typeof globalThis.setTimeout;
270
+
271
+ const fetchFn = mock(
272
+ (_url: string, init?: {signal?: AbortSignal}) =>
273
+ new Promise<Response>((_resolve, reject) => {
274
+ const signal = init?.signal;
275
+ if (signal?.aborted) {
276
+ reject(new Error("aborted"));
277
+ return;
278
+ }
279
+ signal?.addEventListener("abort", () => reject(new Error("aborted")));
280
+ })
281
+ );
282
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
283
+
284
+ try {
285
+ const {unmount} = renderHook(
286
+ () =>
287
+ useServerStatus({
288
+ healthUrl: "http://localhost:4000/health",
289
+ pollIntervalMs: 60_000,
290
+ }),
291
+ {wrapper: createWrapper(store)}
292
+ );
293
+
294
+ await act(async () => {
295
+ await new Promise((resolve) => originalSetTimeout(resolve, 50));
296
+ });
297
+
298
+ expect(fetchFn).toHaveBeenCalled();
299
+ expect(store.getState().offline.isOnline).toBe(false);
300
+ unmount();
301
+ } finally {
302
+ globalThis.setTimeout = originalSetTimeout;
303
+ }
304
+ });
305
+
238
306
  it("triggers health check when browser fires online event", async () => {
239
307
  store.dispatch(setOnlineStatus(false));
240
308
  const fetchFn = mock(async () => new Response("ok", {status: 200}));
@@ -196,6 +196,21 @@ describe("useTerrenoFeatureFlags ref-count cleanup", () => {
196
196
  await OpenFeature.setProviderAndWait(refcountTestDomain, NOOP_PROVIDER);
197
197
  });
198
198
 
199
+ it("awaits pending provider switch during cleanup when unmount overlaps setProviderAndWait", async () => {
200
+ const {api} = buildApi({data: {alpha: boolDef("on")}});
201
+ // Render and immediately unmount before the provider switch resolves.
202
+ // This exercises the `await pending` path (line 119) in the cleanup.
203
+ const {unmount} = renderHook(() =>
204
+ useTerrenoFeatureFlags(api as never, {domain: refcountTestDomain, userId: "u-fast"})
205
+ );
206
+ // Unmount immediately — cleanup fires while the provider switch is still in-flight
207
+ unmount();
208
+ // Allow microtasks (the pending await + NOOP install) to settle
209
+ await new Promise((r) => setTimeout(r, 200));
210
+ // After cleanup the domain should have fallen back to NOOP
211
+ expect(OpenFeature.getClient(refcountTestDomain).getBooleanValue("alpha", false)).toBe(false);
212
+ });
213
+
199
214
  it("installs NOOP provider after the last hook instance unmounts", async () => {
200
215
  const {api} = buildApi({data: {alpha: boolDef("on")}});
201
216
  const {unmount} = renderHook(() =>
@@ -219,4 +234,29 @@ describe("useTerrenoFeatureFlags ref-count cleanup", () => {
219
234
  {timeout: 5000}
220
235
  );
221
236
  });
237
+
238
+ it("keeps the provider installed while another hook instance is still mounted", async () => {
239
+ const {api} = buildApi({data: {alpha: boolDef("on")}});
240
+ // Two instances share the domain, so the ref count reaches 2.
241
+ const first = renderHook(() =>
242
+ useTerrenoFeatureFlags(api as never, {domain: refcountTestDomain, userId: "u1"})
243
+ );
244
+ const second = renderHook(() =>
245
+ useTerrenoFeatureFlags(api as never, {domain: refcountTestDomain, userId: "u1"})
246
+ );
247
+ await waitFor(
248
+ () => {
249
+ expect(OpenFeature.getClient(refcountTestDomain).getBooleanValue("alpha", false)).toBe(
250
+ true
251
+ );
252
+ },
253
+ {timeout: 5000}
254
+ );
255
+ // Unmounting one instance decrements the ref count to 1 (the else branch),
256
+ // so the provider must remain installed rather than falling back to NOOP.
257
+ first.unmount();
258
+ await new Promise((r) => setTimeout(r, 200));
259
+ expect(OpenFeature.getClient(refcountTestDomain).getBooleanValue("alpha", false)).toBe(true);
260
+ second.unmount();
261
+ });
222
262
  });
@@ -0,0 +1,226 @@
1
+ import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
2
+ import {configureStore} from "@reduxjs/toolkit";
3
+ import {act, renderHook} from "@testing-library/react-native";
4
+ import Constants from "expo-constants";
5
+ import React from "react";
6
+ import {Provider} from "react-redux";
7
+
8
+ // Capture the AppState "change" handler so tests can simulate foreground transitions.
9
+ let appStateChangeHandler: ((state: string) => void) | undefined;
10
+ const appStateMock = {
11
+ addEventListener: (_event: string, handler: (state: string) => void) => {
12
+ appStateChangeHandler = handler;
13
+ return {remove: () => {}};
14
+ },
15
+ currentState: "active",
16
+ };
17
+
18
+ // Keep this react-native mock a superset of the preload's mock so other test
19
+ // files still resolve AppState / Linking / Platform after this file runs.
20
+ mock.module("react-native", () => ({
21
+ AppState: appStateMock,
22
+ Linking: {openURL: async () => true},
23
+ Platform: {OS: "web"},
24
+ StyleSheet: {create: (s: unknown) => s},
25
+ }));
26
+
27
+ // Force IsWeb=true regardless of load order with the native test files.
28
+ mock.module("./platform", () => ({IsWeb: true}));
29
+
30
+ const {emptySplitApi} = await import("./emptyApi");
31
+ const {useUpgradeCheck} = await import("./useUpgradeCheck");
32
+
33
+ interface VersionCheckPayload {
34
+ message?: string;
35
+ pollingIntervalMs?: number;
36
+ status: "ok" | "warning" | "required";
37
+ updateUrl?: string;
38
+ }
39
+
40
+ const constantsWithExtra = Constants as unknown as {
41
+ expoConfig: {extra: Record<string, unknown>};
42
+ };
43
+
44
+ const createTestStore = () =>
45
+ configureStore({
46
+ middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(emptySplitApi.middleware),
47
+ reducer: {[emptySplitApi.reducerPath]: emptySplitApi.reducer},
48
+ });
49
+
50
+ const createWrapper = (store: ReturnType<typeof createTestStore>) => {
51
+ const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) =>
52
+ React.createElement(Provider, {children, store});
53
+ return Wrapper;
54
+ };
55
+
56
+ const mockFetchWith = (payload: VersionCheckPayload): ReturnType<typeof mock> => {
57
+ const fetchFn = mock(
58
+ async () =>
59
+ new Response(JSON.stringify(payload), {
60
+ headers: {"content-type": "application/json"},
61
+ status: 200,
62
+ })
63
+ );
64
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
65
+ return fetchFn;
66
+ };
67
+
68
+ const flush = async (ms = 60): Promise<void> => {
69
+ await act(async () => {
70
+ await new Promise((resolve) => setTimeout(resolve, ms));
71
+ });
72
+ };
73
+
74
+ describe("useUpgradeCheck (web)", () => {
75
+ let originalFetch: typeof globalThis.fetch;
76
+ let originalWindow: unknown;
77
+
78
+ beforeEach(() => {
79
+ originalFetch = globalThis.fetch;
80
+ originalWindow = (globalThis as {window?: unknown}).window;
81
+ appStateChangeHandler = undefined;
82
+ appStateMock.currentState = "active";
83
+ constantsWithExtra.expoConfig.extra.buildNumber = 100;
84
+ });
85
+
86
+ afterEach(() => {
87
+ globalThis.fetch = originalFetch;
88
+ (globalThis as {window?: unknown}).window = originalWindow;
89
+ delete constantsWithExtra.expoConfig.extra.buildNumber;
90
+ emptySplitApi.util.resetApiState();
91
+ });
92
+
93
+ it("does not call the backend when the build number is unavailable", async () => {
94
+ delete constantsWithExtra.expoConfig.extra.buildNumber;
95
+ const fetchFn = mockFetchWith({status: "ok"});
96
+
97
+ const {result, unmount} = renderHook(() => useUpgradeCheck(), {
98
+ wrapper: createWrapper(createTestStore()),
99
+ });
100
+ await flush();
101
+
102
+ expect(fetchFn).not.toHaveBeenCalled();
103
+ expect(result.current.isRequired).toBe(false);
104
+ expect(result.current.isWarning).toBe(false);
105
+ unmount();
106
+ });
107
+
108
+ it("flags a required upgrade and adopts the server polling interval", async () => {
109
+ const fetchFn = mockFetchWith({
110
+ message: "Please update",
111
+ pollingIntervalMs: 999_999,
112
+ status: "required",
113
+ updateUrl: "https://example.com/update",
114
+ });
115
+
116
+ const {result, unmount} = renderHook(() => useUpgradeCheck(), {
117
+ wrapper: createWrapper(createTestStore()),
118
+ });
119
+ await flush();
120
+
121
+ expect(fetchFn).toHaveBeenCalled();
122
+ expect(result.current.isRequired).toBe(true);
123
+ expect(result.current.requiredMessage).toBe("Please update");
124
+ expect(result.current.isWarning).toBe(false);
125
+ // On web the app can always self-update via reload.
126
+ expect(result.current.canUpdate).toBe(true);
127
+ unmount();
128
+ });
129
+
130
+ it("flags a warning upgrade and increments the warning check count", async () => {
131
+ const fetchFn = mockFetchWith({message: "Update soon", status: "warning"});
132
+
133
+ const {result, unmount} = renderHook(() => useUpgradeCheck(), {
134
+ wrapper: createWrapper(createTestStore()),
135
+ });
136
+ await flush();
137
+
138
+ expect(fetchFn).toHaveBeenCalled();
139
+ expect(result.current.isWarning).toBe(true);
140
+ expect(result.current.warningMessage).toBe("Update soon");
141
+ expect(result.current.warningCheckCount).toBe(1);
142
+ expect(result.current.isRequired).toBe(false);
143
+ unmount();
144
+ });
145
+
146
+ it("clears upgrade state for an ok status", async () => {
147
+ mockFetchWith({status: "ok"});
148
+
149
+ const {result, unmount} = renderHook(() => useUpgradeCheck(), {
150
+ wrapper: createWrapper(createTestStore()),
151
+ });
152
+ await flush();
153
+
154
+ expect(result.current.isRequired).toBe(false);
155
+ expect(result.current.isWarning).toBe(false);
156
+ unmount();
157
+ });
158
+
159
+ it("swallows version-check failures and stays in the default state", async () => {
160
+ const fetchFn = mock(async () => {
161
+ throw new Error("network down");
162
+ });
163
+ globalThis.fetch = fetchFn as unknown as typeof fetch;
164
+
165
+ const {result, unmount} = renderHook(() => useUpgradeCheck(), {
166
+ wrapper: createWrapper(createTestStore()),
167
+ });
168
+ // Wait long enough for RTK Query's retry backoff to exhaust and reject.
169
+ await flush(7000);
170
+
171
+ expect(result.current.isRequired).toBe(false);
172
+ expect(result.current.isWarning).toBe(false);
173
+ unmount();
174
+ }, 20_000);
175
+
176
+ it("reloads the page on web when onUpdate is invoked", async () => {
177
+ mockFetchWith({status: "required", updateUrl: "https://example.com/update"});
178
+ const reload = mock(() => {});
179
+ (globalThis as {window?: unknown}).window = {location: {reload}};
180
+
181
+ const {result, unmount} = renderHook(() => useUpgradeCheck(), {
182
+ wrapper: createWrapper(createTestStore()),
183
+ });
184
+ await flush();
185
+
186
+ act(() => {
187
+ result.current.onUpdate();
188
+ });
189
+
190
+ expect(reload).toHaveBeenCalledTimes(1);
191
+ unmount();
192
+ });
193
+
194
+ it("polls repeatedly using the fallback interval", async () => {
195
+ const fetchFn = mockFetchWith({status: "ok"});
196
+
197
+ const {unmount} = renderHook(() => useUpgradeCheck({pollingIntervalMs: 20}), {
198
+ wrapper: createWrapper(createTestStore()),
199
+ });
200
+ await flush(120);
201
+
202
+ expect(fetchFn.mock.calls.length).toBeGreaterThan(1);
203
+ unmount();
204
+ });
205
+
206
+ it("re-checks when the app returns to the foreground", async () => {
207
+ const fetchFn = mockFetchWith({status: "ok"});
208
+
209
+ const {unmount} = renderHook(() => useUpgradeCheck({recheckOnForeground: true}), {
210
+ wrapper: createWrapper(createTestStore()),
211
+ });
212
+ await flush();
213
+
214
+ const initialCalls = fetchFn.mock.calls.length;
215
+ expect(appStateChangeHandler).toBeDefined();
216
+
217
+ await act(async () => {
218
+ appStateChangeHandler?.("background");
219
+ appStateChangeHandler?.("active");
220
+ await new Promise((resolve) => setTimeout(resolve, 30));
221
+ });
222
+
223
+ expect(fetchFn.mock.calls.length).toBeGreaterThan(initialCalls);
224
+ unmount();
225
+ });
226
+ });
@@ -0,0 +1,124 @@
1
+ import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
2
+ import {configureStore} from "@reduxjs/toolkit";
3
+ import {act, renderHook} from "@testing-library/react-native";
4
+ import Constants from "expo-constants";
5
+ import React from "react";
6
+ import {Provider} from "react-redux";
7
+
8
+ const openedUrls: string[] = [];
9
+
10
+ // Keep this react-native mock a superset of the preload's mock so other test
11
+ // files still resolve AppState / Linking / Platform after this file runs.
12
+ mock.module("react-native", () => ({
13
+ AppState: {
14
+ addEventListener: () => ({remove: () => {}}),
15
+ currentState: "active",
16
+ },
17
+ Linking: {
18
+ openURL: async (url: string) => {
19
+ openedUrls.push(url);
20
+ return true;
21
+ },
22
+ },
23
+ Platform: {OS: "ios"},
24
+ StyleSheet: {create: (s: unknown) => s},
25
+ }));
26
+
27
+ // Force IsWeb=false so the mobile update path is exercised.
28
+ mock.module("./platform", () => ({IsWeb: false}));
29
+
30
+ const {emptySplitApi} = await import("./emptyApi");
31
+ const {useUpgradeCheck} = await import("./useUpgradeCheck");
32
+
33
+ interface VersionCheckPayload {
34
+ message?: string;
35
+ status: "ok" | "warning" | "required";
36
+ updateUrl?: string;
37
+ }
38
+
39
+ const constantsWithExtra = Constants as unknown as {
40
+ expoConfig: {extra: Record<string, unknown>};
41
+ };
42
+
43
+ const createTestStore = () =>
44
+ configureStore({
45
+ middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(emptySplitApi.middleware),
46
+ reducer: {[emptySplitApi.reducerPath]: emptySplitApi.reducer},
47
+ });
48
+
49
+ const createWrapper = (store: ReturnType<typeof createTestStore>) => {
50
+ const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) =>
51
+ React.createElement(Provider, {children, store});
52
+ return Wrapper;
53
+ };
54
+
55
+ const mockFetchWith = (payload: VersionCheckPayload): void => {
56
+ globalThis.fetch = mock(
57
+ async () =>
58
+ new Response(JSON.stringify(payload), {
59
+ headers: {"content-type": "application/json"},
60
+ status: 200,
61
+ })
62
+ ) as unknown as typeof fetch;
63
+ };
64
+
65
+ const flush = async (ms = 60): Promise<void> => {
66
+ await act(async () => {
67
+ await new Promise((resolve) => setTimeout(resolve, ms));
68
+ });
69
+ };
70
+
71
+ describe("useUpgradeCheck (native)", () => {
72
+ let originalFetch: typeof globalThis.fetch;
73
+
74
+ beforeEach(() => {
75
+ originalFetch = globalThis.fetch;
76
+ openedUrls.length = 0;
77
+ constantsWithExtra.expoConfig.extra.buildNumber = 100;
78
+ });
79
+
80
+ afterEach(() => {
81
+ globalThis.fetch = originalFetch;
82
+ delete constantsWithExtra.expoConfig.extra.buildNumber;
83
+ emptySplitApi.util.resetApiState();
84
+ });
85
+
86
+ it("opens the update URL on native when onUpdate is invoked", async () => {
87
+ mockFetchWith({status: "required", updateUrl: "https://example.com/app-update"});
88
+
89
+ const {result, unmount} = renderHook(() => useUpgradeCheck(), {
90
+ wrapper: createWrapper(createTestStore()),
91
+ });
92
+ await flush();
93
+
94
+ // canUpdate on native depends on having a resolved update URL.
95
+ expect(result.current.canUpdate).toBe(true);
96
+
97
+ await act(async () => {
98
+ result.current.onUpdate();
99
+ await new Promise((resolve) => setTimeout(resolve, 10));
100
+ });
101
+
102
+ expect(openedUrls).toEqual(["https://example.com/app-update"]);
103
+ unmount();
104
+ });
105
+
106
+ it("does not open a URL on native when no update URL is available", async () => {
107
+ mockFetchWith({status: "required"});
108
+
109
+ const {result, unmount} = renderHook(() => useUpgradeCheck(), {
110
+ wrapper: createWrapper(createTestStore()),
111
+ });
112
+ await flush();
113
+
114
+ expect(result.current.canUpdate).toBe(false);
115
+
116
+ await act(async () => {
117
+ result.current.onUpdate();
118
+ await new Promise((resolve) => setTimeout(resolve, 10));
119
+ });
120
+
121
+ expect(openedUrls).toEqual([]);
122
+ unmount();
123
+ });
124
+ });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=constants.isolated.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"constants.isolated.d.ts","sourceRoot":"","sources":["../../src/isolated/constants.isolated.ts"],"names":[],"mappings":""}
@@ -1 +0,0 @@
1
- {"version":3,"file":"constants.isolated.js","sourceRoot":"","sources":["../../src/isolated/constants.isolated.ts"],"names":[],"mappings":"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"}