@terreno/rtk 0.13.0 → 0.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.
- package/dist/authSlice.test.js +2 -10
- package/dist/authSlice.test.js.map +1 -1
- package/dist/authSliceNative.test.js +11 -3
- package/dist/authSliceNative.test.js.map +1 -1
- package/dist/betterAuthSlice.test.js +1 -25
- package/dist/betterAuthSlice.test.js.map +1 -1
- package/dist/buildNumber.test.js +19 -0
- package/dist/buildNumber.test.js.map +1 -1
- package/dist/constants.d.ts +3 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +11 -3
- package/dist/constants.js.map +1 -1
- package/dist/constants.test.js +44 -2
- package/dist/constants.test.js.map +1 -1
- package/dist/emptyApi.d.ts +2 -0
- package/dist/emptyApi.d.ts.map +1 -1
- package/dist/emptyApi.js +5 -0
- package/dist/emptyApi.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/isolated/constants.isolated.js +7 -1
- package/dist/isolated/constants.isolated.js.map +1 -1
- package/dist/isolated/offlineMiddleware.isolated.d.ts +2 -0
- package/dist/isolated/offlineMiddleware.isolated.d.ts.map +1 -0
- package/dist/isolated/offlineMiddleware.isolated.js +516 -0
- package/dist/isolated/offlineMiddleware.isolated.js.map +1 -0
- package/dist/isolated/offlineMiddleware.native.isolated.d.ts +2 -0
- package/dist/isolated/offlineMiddleware.native.isolated.d.ts.map +1 -0
- package/dist/isolated/offlineMiddleware.native.isolated.js +70 -0
- package/dist/isolated/offlineMiddleware.native.isolated.js.map +1 -0
- package/dist/isolated/useUpgradeCheck.mobile.isolated.d.ts +2 -0
- package/dist/isolated/useUpgradeCheck.mobile.isolated.d.ts.map +1 -0
- package/dist/{useUpgradeCheck.mobile.test.js → isolated/useUpgradeCheck.mobile.isolated.js} +13 -5
- package/dist/isolated/useUpgradeCheck.mobile.isolated.js.map +1 -0
- package/dist/isolated/useUpgradeCheck.web.isolated.d.ts +2 -0
- package/dist/isolated/useUpgradeCheck.web.isolated.d.ts.map +1 -0
- package/dist/{useUpgradeCheck.test.js → isolated/useUpgradeCheck.web.isolated.js} +37 -7
- package/dist/isolated/useUpgradeCheck.web.isolated.js.map +1 -0
- package/dist/mongooseSlice.test.js +1 -1
- package/dist/mongooseSlice.test.js.map +1 -1
- package/dist/offlineGate.d.ts +8 -0
- package/dist/offlineGate.d.ts.map +1 -0
- package/dist/offlineGate.js +20 -0
- package/dist/offlineGate.js.map +1 -0
- package/dist/offlineGate.test.d.ts +2 -0
- package/dist/offlineGate.test.d.ts.map +1 -0
- package/dist/offlineGate.test.js +46 -0
- package/dist/offlineGate.test.js.map +1 -0
- package/dist/offlineMiddleware.d.ts +46 -0
- package/dist/offlineMiddleware.d.ts.map +1 -0
- package/dist/offlineMiddleware.js +591 -0
- package/dist/offlineMiddleware.js.map +1 -0
- package/dist/offlineMiddleware.test.d.ts +2 -0
- package/dist/offlineMiddleware.test.d.ts.map +1 -0
- package/dist/offlineMiddleware.test.js +145 -0
- package/dist/offlineMiddleware.test.js.map +1 -0
- package/dist/offlineSlice.d.ts +232 -0
- package/dist/offlineSlice.d.ts.map +1 -0
- package/dist/offlineSlice.js +65 -0
- package/dist/offlineSlice.js.map +1 -0
- package/dist/offlineSlice.test.d.ts +2 -0
- package/dist/offlineSlice.test.d.ts.map +1 -0
- package/dist/offlineSlice.test.js +153 -0
- package/dist/offlineSlice.test.js.map +1 -0
- package/dist/realtime.d.ts +123 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +299 -0
- package/dist/realtime.js.map +1 -0
- package/dist/realtime.test.d.ts +2 -0
- package/dist/realtime.test.d.ts.map +1 -0
- package/dist/realtime.test.js +339 -0
- package/dist/realtime.test.js.map +1 -0
- package/dist/socket.d.ts.map +1 -1
- package/dist/socket.js +29 -28
- package/dist/socket.js.map +1 -1
- package/dist/sync.d.ts +35 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +205 -0
- package/dist/sync.js.map +1 -0
- package/dist/tagGenerator.d.ts +20 -1
- package/dist/tagGenerator.d.ts.map +1 -1
- package/dist/tagGenerator.js +1 -2
- package/dist/tagGenerator.js.map +1 -1
- package/dist/useOfflineStatus.d.ts +33 -0
- package/dist/useOfflineStatus.d.ts.map +1 -0
- package/dist/useOfflineStatus.js +43 -0
- package/dist/useOfflineStatus.js.map +1 -0
- package/dist/useOfflineStatus.test.d.ts +2 -0
- package/dist/useOfflineStatus.test.d.ts.map +1 -0
- package/dist/useOfflineStatus.test.js +119 -0
- package/dist/useOfflineStatus.test.js.map +1 -0
- package/dist/useRealtimeDebug.d.ts +6 -0
- package/dist/useRealtimeDebug.d.ts.map +1 -0
- package/dist/useRealtimeDebug.js +41 -0
- package/dist/useRealtimeDebug.js.map +1 -0
- package/dist/useServerStatus.d.ts +29 -0
- package/dist/useServerStatus.d.ts.map +1 -0
- package/dist/useServerStatus.js +89 -0
- package/dist/useServerStatus.js.map +1 -0
- package/dist/useUpgradeCheck.d.ts +7 -3
- package/dist/useUpgradeCheck.d.ts.map +1 -1
- package/dist/useUpgradeCheck.js +27 -13
- package/dist/useUpgradeCheck.js.map +1 -1
- package/package.json +2 -2
- package/src/authSlice.test.ts +2 -11
- package/src/authSliceNative.test.ts +11 -4
- package/src/betterAuthSlice.test.ts +1 -25
- package/src/buildNumber.test.ts +19 -0
- package/src/constants.test.ts +54 -1
- package/src/constants.ts +14 -3
- package/src/emptyApi.ts +8 -0
- package/src/index.ts +8 -0
- package/src/isolated/constants.isolated.ts +7 -3
- package/src/isolated/offlineMiddleware.isolated.ts +696 -0
- package/src/isolated/offlineMiddleware.native.isolated.ts +80 -0
- package/src/{useUpgradeCheck.mobile.test.ts → isolated/useUpgradeCheck.mobile.isolated.ts} +13 -4
- package/src/{useUpgradeCheck.test.ts → isolated/useUpgradeCheck.web.isolated.ts} +48 -6
- package/src/mongooseSlice.test.ts +1 -1
- package/src/offlineGate.test.ts +55 -0
- package/src/offlineGate.ts +25 -0
- package/src/offlineMiddleware.test.ts +187 -0
- package/src/offlineMiddleware.ts +718 -0
- package/src/offlineSlice.test.ts +204 -0
- package/src/offlineSlice.ts +130 -0
- package/src/realtime.test.ts +404 -0
- package/src/realtime.ts +408 -0
- package/src/socket.ts +33 -32
- package/src/sync.ts +310 -0
- package/src/tagGenerator.ts +29 -11
- package/src/useOfflineStatus.test.ts +157 -0
- package/src/useOfflineStatus.ts +82 -0
- package/src/useRealtimeDebug.ts +51 -0
- package/src/useServerStatus.ts +122 -0
- package/src/useUpgradeCheck.ts +35 -13
- package/dist/isolated/useUpgradeCheck.isolated.d.ts +0 -2
- package/dist/isolated/useUpgradeCheck.isolated.d.ts.map +0 -1
- package/dist/isolated/useUpgradeCheck.isolated.js +0 -153
- package/dist/isolated/useUpgradeCheck.isolated.js.map +0 -1
- package/dist/useUpgradeCheck.mobile.test.d.ts +0 -2
- package/dist/useUpgradeCheck.mobile.test.d.ts.map +0 -1
- package/dist/useUpgradeCheck.mobile.test.js.map +0 -1
- package/dist/useUpgradeCheck.test.d.ts +0 -2
- package/dist/useUpgradeCheck.test.d.ts.map +0 -1
- package/dist/useUpgradeCheck.test.js.map +0 -1
- package/src/isolated/useUpgradeCheck.isolated.ts +0 -201
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {describe, expect, it, mock} from "bun:test";
|
|
2
|
+
|
|
3
|
+
mock.module("react-native", () => ({
|
|
4
|
+
Platform: {OS: "ios"},
|
|
5
|
+
StyleSheet: {create: (styles: unknown) => styles},
|
|
6
|
+
}));
|
|
7
|
+
mock.module("../platform", () => ({IsWeb: false}));
|
|
8
|
+
mock.module("../authSlice", () => ({
|
|
9
|
+
getAuthToken: async () => null,
|
|
10
|
+
selectCurrentUserId: (state: {auth?: {userId?: string}}) => state.auth?.userId,
|
|
11
|
+
}));
|
|
12
|
+
mock.module("../constants", () => ({
|
|
13
|
+
baseUrl: "http://localhost:4000",
|
|
14
|
+
LOGOUT_ACTION_TYPE: "auth/logout",
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
let networkListener: ((state: {isConnected?: boolean}) => void) | undefined;
|
|
18
|
+
const removeNetworkListener = mock(() => {});
|
|
19
|
+
mock.module("expo-network", () => ({
|
|
20
|
+
addNetworkStateListener: (listener: (state: {isConnected?: boolean}) => void) => {
|
|
21
|
+
networkListener = listener;
|
|
22
|
+
return {remove: removeNetworkListener};
|
|
23
|
+
},
|
|
24
|
+
getNetworkStateAsync: async () => ({isConnected: false}),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const {configureStore} = await import("@reduxjs/toolkit");
|
|
28
|
+
const {createApi, fetchBaseQuery} = await import("@reduxjs/toolkit/query");
|
|
29
|
+
const {createOfflineMiddleware} = await import("../offlineMiddleware");
|
|
30
|
+
const {selectIsOnline} = await import("../offlineSlice");
|
|
31
|
+
|
|
32
|
+
const api = createApi({
|
|
33
|
+
baseQuery: fetchBaseQuery({baseUrl: "http://localhost:4000"}),
|
|
34
|
+
endpoints: (builder) => ({
|
|
35
|
+
patchTodosById: builder.mutation({
|
|
36
|
+
query: (args: {id: string; body: Record<string, unknown>}) => ({
|
|
37
|
+
body: args.body,
|
|
38
|
+
method: "PATCH",
|
|
39
|
+
url: `/todos/${args.id}`,
|
|
40
|
+
}),
|
|
41
|
+
}),
|
|
42
|
+
}),
|
|
43
|
+
reducerPath: "terreno-rtk-native",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const createNativeStore = () => {
|
|
47
|
+
const offline = createOfflineMiddleware({
|
|
48
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic API type is intentionally broad.
|
|
49
|
+
api: api as any,
|
|
50
|
+
endpoints: ["patchTodosById"],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return configureStore({
|
|
54
|
+
middleware: (getDefault) =>
|
|
55
|
+
getDefault({serializableCheck: false}).concat(api.middleware, offline.middleware),
|
|
56
|
+
reducer: {
|
|
57
|
+
[api.reducerPath]: api.reducer,
|
|
58
|
+
offline: offline.offlineReducer,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const waitForEffects = async (): Promise<void> => {
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
describe("createOfflineMiddleware native network monitoring", () => {
|
|
68
|
+
it("uses expo-network initial state and subscription updates", async () => {
|
|
69
|
+
const store = createNativeStore();
|
|
70
|
+
|
|
71
|
+
store.dispatch({type: "init-native-network-monitoring"});
|
|
72
|
+
await waitForEffects();
|
|
73
|
+
|
|
74
|
+
expect(selectIsOnline(store.getState())).toBe(false);
|
|
75
|
+
networkListener?.({isConnected: true});
|
|
76
|
+
expect(selectIsOnline(store.getState())).toBe(true);
|
|
77
|
+
networkListener?.({isConnected: false});
|
|
78
|
+
expect(selectIsOnline(store.getState())).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
|
|
2
|
-
import {act, renderHook, waitFor} from "@testing-library/react-native";
|
|
3
2
|
|
|
4
3
|
// ---------------------------------------------------------------------------
|
|
5
4
|
// Mutable refs
|
|
@@ -8,18 +7,19 @@ let mockBuildNumber: number | undefined = 42;
|
|
|
8
7
|
|
|
9
8
|
interface MockVersionCheckResponse {
|
|
10
9
|
message?: string;
|
|
10
|
+
pollingIntervalMs?: number;
|
|
11
11
|
status: "ok" | "warning" | "required";
|
|
12
12
|
updateUrl?: string;
|
|
13
13
|
}
|
|
14
14
|
const mockUnwrap = mock((): Promise<MockVersionCheckResponse> => Promise.resolve({status: "ok"}));
|
|
15
15
|
const mockTrigger = mock((..._args: unknown[]) => ({unwrap: mockUnwrap}));
|
|
16
16
|
|
|
17
|
-
mock.module("
|
|
17
|
+
mock.module("../emptyApi", () => ({
|
|
18
18
|
useLazyGetVersionCheckQuery: () => [mockTrigger],
|
|
19
19
|
}));
|
|
20
20
|
|
|
21
21
|
// IsWeb is false to exercise native code paths
|
|
22
|
-
mock.module("
|
|
22
|
+
mock.module("../platform", () => ({
|
|
23
23
|
IsWeb: false,
|
|
24
24
|
}));
|
|
25
25
|
|
|
@@ -68,7 +68,16 @@ const warnCalls: unknown[][] = [];
|
|
|
68
68
|
const originalDebug = console.debug;
|
|
69
69
|
const originalWarn = console.warn;
|
|
70
70
|
|
|
71
|
-
import
|
|
71
|
+
// Import after all mock.module calls. Using `await import` instead of a
|
|
72
|
+
// top-level static import ensures the mocks (especially for react-native) are
|
|
73
|
+
// already in place when these modules' transitive imports resolve. With a
|
|
74
|
+
// static import, bun 1.3.x has been observed to evaluate the real `react-native`
|
|
75
|
+
// when this file is loaded after another test file already pulled it in, which
|
|
76
|
+
// blows up with `Export named 'AppState' not found`. The
|
|
77
|
+
// `@testing-library/react-native` import is also deferred because it pulls in
|
|
78
|
+
// the real react-native module during its own evaluation.
|
|
79
|
+
const {act, renderHook, waitFor} = await import("@testing-library/react-native");
|
|
80
|
+
const {useUpgradeCheck} = await import("../useUpgradeCheck");
|
|
72
81
|
|
|
73
82
|
const flushPromises = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
|
74
83
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {afterEach, beforeEach, describe, expect, it, mock} from "bun:test";
|
|
2
|
-
import {act, renderHook, waitFor} from "@testing-library/react-native";
|
|
3
2
|
|
|
4
3
|
// ---------------------------------------------------------------------------
|
|
5
4
|
// Mutable refs that tests can tweak between runs
|
|
@@ -9,19 +8,20 @@ let mockBuildNumber: number | undefined = 42;
|
|
|
9
8
|
// Trigger + unwrap mocks for useLazyGetVersionCheckQuery.
|
|
10
9
|
interface MockVersionCheckResponse {
|
|
11
10
|
message?: string;
|
|
11
|
+
pollingIntervalMs?: number;
|
|
12
12
|
status: "ok" | "warning" | "required";
|
|
13
13
|
updateUrl?: string;
|
|
14
14
|
}
|
|
15
15
|
const mockUnwrap = mock((): Promise<MockVersionCheckResponse> => Promise.resolve({status: "ok"}));
|
|
16
16
|
const mockTrigger = mock((..._args: unknown[]) => ({unwrap: mockUnwrap}));
|
|
17
17
|
|
|
18
|
-
mock.module("
|
|
18
|
+
mock.module("../emptyApi", () => ({
|
|
19
19
|
useLazyGetVersionCheckQuery: () => [mockTrigger],
|
|
20
20
|
}));
|
|
21
21
|
|
|
22
22
|
// IsWeb is always true in tests (Platform.OS mocked as "web" in preload).
|
|
23
23
|
// We cannot change it per-test because bun snapshots module exports.
|
|
24
|
-
mock.module("
|
|
24
|
+
mock.module("../platform", () => ({
|
|
25
25
|
IsWeb: true,
|
|
26
26
|
}));
|
|
27
27
|
|
|
@@ -78,8 +78,16 @@ const debugCalls: unknown[][] = [];
|
|
|
78
78
|
const originalDebug = console.debug;
|
|
79
79
|
const originalWarn = console.warn;
|
|
80
80
|
|
|
81
|
-
//
|
|
82
|
-
import
|
|
81
|
+
// Import after all mock.module calls. Using `await import` instead of a
|
|
82
|
+
// top-level static import ensures the mocks (especially for react-native) are
|
|
83
|
+
// already in place when these modules' transitive imports resolve. With a
|
|
84
|
+
// static import, bun 1.3.x has been observed to evaluate the real `react-native`
|
|
85
|
+
// when this file is loaded after another test file already pulled it in, which
|
|
86
|
+
// blows up with `Export named 'AppState' not found`. The
|
|
87
|
+
// `@testing-library/react-native` import is also deferred because it pulls in
|
|
88
|
+
// the real react-native module during its own evaluation.
|
|
89
|
+
const {act, renderHook, waitFor} = await import("@testing-library/react-native");
|
|
90
|
+
const {useUpgradeCheck} = await import("../useUpgradeCheck");
|
|
83
91
|
|
|
84
92
|
// Helper: flush microtasks (lets .then() chains resolve)
|
|
85
93
|
const flushPromises = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
|
@@ -310,11 +318,45 @@ describe("useUpgradeCheck", () => {
|
|
|
310
318
|
expect(capturedIntervalMs).toBe(60_000);
|
|
311
319
|
});
|
|
312
320
|
|
|
313
|
-
it("does not set up interval when pollingIntervalMs is omitted", () => {
|
|
321
|
+
it("does not set up interval when pollingIntervalMs is omitted and server returns none", () => {
|
|
314
322
|
renderHook(() => useUpgradeCheck());
|
|
315
323
|
expect(mockSetInterval).not.toHaveBeenCalled();
|
|
316
324
|
});
|
|
317
325
|
|
|
326
|
+
it("adopts server-returned pollingIntervalMs after first response", async () => {
|
|
327
|
+
mockUnwrap.mockImplementation(() =>
|
|
328
|
+
Promise.resolve({pollingIntervalMs: 3_600_000, status: "ok" as const})
|
|
329
|
+
);
|
|
330
|
+
renderHook(() => useUpgradeCheck());
|
|
331
|
+
|
|
332
|
+
await act(async () => {
|
|
333
|
+
await flushPromises();
|
|
334
|
+
});
|
|
335
|
+
// Flush the secondary render triggered by setActivePollingIntervalMs
|
|
336
|
+
await act(async () => {});
|
|
337
|
+
|
|
338
|
+
expect(mockSetInterval).toHaveBeenCalledWith(expect.any(Function), 3_600_000);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("server pollingIntervalMs overrides the local fallback prop", async () => {
|
|
342
|
+
mockUnwrap.mockImplementation(() =>
|
|
343
|
+
Promise.resolve({pollingIntervalMs: 1_800_000, status: "ok" as const})
|
|
344
|
+
);
|
|
345
|
+
renderHook(() => useUpgradeCheck({pollingIntervalMs: 60_000}));
|
|
346
|
+
|
|
347
|
+
// Initial interval from prop
|
|
348
|
+
expect(mockSetInterval).toHaveBeenCalledWith(expect.any(Function), 60_000);
|
|
349
|
+
|
|
350
|
+
await act(async () => {
|
|
351
|
+
await flushPromises();
|
|
352
|
+
});
|
|
353
|
+
// Flush the secondary render triggered by setActivePollingIntervalMs
|
|
354
|
+
await act(async () => {});
|
|
355
|
+
|
|
356
|
+
// Interval restarts with the server-configured value
|
|
357
|
+
expect(mockSetInterval).toHaveBeenCalledWith(expect.any(Function), 1_800_000);
|
|
358
|
+
});
|
|
359
|
+
|
|
318
360
|
it("cleans up interval on unmount", () => {
|
|
319
361
|
const {unmount} = renderHook(() => useUpgradeCheck({pollingIntervalMs: 60_000}));
|
|
320
362
|
unmount();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
|
|
1
2
|
import {describe, expect, it} from "bun:test";
|
|
2
3
|
|
|
3
4
|
import {type ListResponse, populateId} from "./mongooseSlice";
|
|
@@ -38,7 +39,6 @@ describe("populateId", () => {
|
|
|
38
39
|
|
|
39
40
|
it("handles sparse arrays without throwing", () => {
|
|
40
41
|
const sparse: ListResponse<{_id: string}> = {
|
|
41
|
-
// biome-ignore lint/suspicious/noExplicitAny: Simulating malformed API payloads.
|
|
42
42
|
data: [undefined as any, {_id: "found"}],
|
|
43
43
|
};
|
|
44
44
|
expect(populateId("found", sparse)).toEqual({_id: "found"});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import {configureStore} from "@reduxjs/toolkit";
|
|
3
|
+
|
|
4
|
+
import {configureOfflineMutationEndpoints, shouldDeferOfflineMutation} from "./offlineGate";
|
|
5
|
+
import {offlineReducer, setOnlineStatus} from "./offlineSlice";
|
|
6
|
+
|
|
7
|
+
const createTestStore = () =>
|
|
8
|
+
configureStore({
|
|
9
|
+
reducer: {offline: offlineReducer},
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("offlineGate", () => {
|
|
13
|
+
let store: ReturnType<typeof createTestStore>;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
store = createTestStore();
|
|
17
|
+
configureOfflineMutationEndpoints([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("configureOfflineMutationEndpoints", () => {
|
|
21
|
+
it("registers endpoint names", () => {
|
|
22
|
+
configureOfflineMutationEndpoints(["postTodos", "patchTodosById"]);
|
|
23
|
+
store.dispatch(setOnlineStatus(false));
|
|
24
|
+
expect(shouldDeferOfflineMutation("postTodos", store.getState)).toBe(true);
|
|
25
|
+
expect(shouldDeferOfflineMutation("patchTodosById", store.getState)).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("replaces previous endpoints on re-configure", () => {
|
|
29
|
+
configureOfflineMutationEndpoints(["postTodos"]);
|
|
30
|
+
configureOfflineMutationEndpoints(["patchTodosById"]);
|
|
31
|
+
store.dispatch(setOnlineStatus(false));
|
|
32
|
+
expect(shouldDeferOfflineMutation("postTodos", store.getState)).toBe(false);
|
|
33
|
+
expect(shouldDeferOfflineMutation("patchTodosById", store.getState)).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("shouldDeferOfflineMutation", () => {
|
|
38
|
+
it("returns false for unregistered endpoints", () => {
|
|
39
|
+
configureOfflineMutationEndpoints(["postTodos"]);
|
|
40
|
+
store.dispatch(setOnlineStatus(false));
|
|
41
|
+
expect(shouldDeferOfflineMutation("postUsers", store.getState)).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns false when online even for registered endpoints", () => {
|
|
45
|
+
configureOfflineMutationEndpoints(["postTodos"]);
|
|
46
|
+
expect(shouldDeferOfflineMutation("postTodos", store.getState)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns true when offline for registered endpoints", () => {
|
|
50
|
+
configureOfflineMutationEndpoints(["postTodos"]);
|
|
51
|
+
store.dispatch(setOnlineStatus(false));
|
|
52
|
+
expect(shouldDeferOfflineMutation("postTodos", store.getState)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {type OfflineState, selectIsOnline} from "./offlineSlice";
|
|
2
|
+
|
|
3
|
+
let offlineMutationEndpoints = new Set<string>();
|
|
4
|
+
|
|
5
|
+
/** Register mutation endpoints that should be queued instead of sent while offline. */
|
|
6
|
+
export const configureOfflineMutationEndpoints = (endpoints: string[]): void => {
|
|
7
|
+
offlineMutationEndpoints = new Set(endpoints);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* When true, the base query should return a network error without making a request.
|
|
12
|
+
* The offline middleware will queue the mutation and apply an optimistic update.
|
|
13
|
+
*/
|
|
14
|
+
export const shouldDeferOfflineMutation = (
|
|
15
|
+
endpointName: string,
|
|
16
|
+
// biome-ignore lint/suspicious/noExplicitAny: Redux getState is app-specific
|
|
17
|
+
getState: () => any
|
|
18
|
+
): boolean => {
|
|
19
|
+
if (!offlineMutationEndpoints.has(endpointName)) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const state = getState() as {offline: OfflineState};
|
|
24
|
+
return !selectIsOnline(state);
|
|
25
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import {describe, expect, it} from "bun:test";
|
|
2
|
+
import {configureStore, type UnknownAction} from "@reduxjs/toolkit";
|
|
3
|
+
import type {Api} from "@reduxjs/toolkit/query/react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
buildOptimisticCreateItem,
|
|
7
|
+
createOfflineMiddleware,
|
|
8
|
+
isNetworkFetchError,
|
|
9
|
+
shouldReplayQueuedMutation,
|
|
10
|
+
} from "./offlineMiddleware";
|
|
11
|
+
import {type OfflineState, type QueuedMutation, selectOfflineQueue} from "./offlineSlice";
|
|
12
|
+
|
|
13
|
+
interface QueryEntry {
|
|
14
|
+
data?: {data: Record<string, unknown>[]};
|
|
15
|
+
originalArgs: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface TestApiState {
|
|
19
|
+
queries: Record<string, QueryEntry>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface TestState {
|
|
23
|
+
auth: {userId?: string};
|
|
24
|
+
offline: OfflineState;
|
|
25
|
+
testApi: TestApiState;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const LIST_QUERY_ARGS = {};
|
|
29
|
+
const LIST_UPDATED_AT = "2026-05-23T21:00:00.123Z";
|
|
30
|
+
|
|
31
|
+
const initialTestApiState: TestApiState = {
|
|
32
|
+
queries: {
|
|
33
|
+
"getTodos({})": {
|
|
34
|
+
data: {
|
|
35
|
+
data: [
|
|
36
|
+
{
|
|
37
|
+
_id: "todo-1",
|
|
38
|
+
title: "Original",
|
|
39
|
+
updated: LIST_UPDATED_AT,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
originalArgs: LIST_QUERY_ARGS,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const testApiReducer = (state: TestApiState = initialTestApiState): TestApiState => state;
|
|
49
|
+
|
|
50
|
+
// biome-ignore lint/suspicious/noExplicitAny: Test double covers only fields used by offline middleware.
|
|
51
|
+
const createTestApi = (): Api<any, any, any, any> => {
|
|
52
|
+
return {
|
|
53
|
+
endpoints: {
|
|
54
|
+
getTodos: {
|
|
55
|
+
select: (queryArg: unknown) => {
|
|
56
|
+
return (state: TestState): QueryEntry | undefined => {
|
|
57
|
+
return Object.values(state.testApi.queries).find(
|
|
58
|
+
(entry) => entry.originalArgs === queryArg
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
reducerPath: "testApi",
|
|
65
|
+
util: {
|
|
66
|
+
invalidateTags: () => ({type: "testApi/invalidateTags"}),
|
|
67
|
+
updateQueryData: () => ({type: "testApi/updateQueryData"}),
|
|
68
|
+
},
|
|
69
|
+
// biome-ignore lint/suspicious/noExplicitAny: Test double covers only fields used by offline middleware.
|
|
70
|
+
} as unknown as Api<any, any, any, any>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
describe("isNetworkFetchError", () => {
|
|
74
|
+
it("returns true for TypeError error name", () => {
|
|
75
|
+
expect(isNetworkFetchError({error: {name: "TypeError"}})).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns true for network error messages", () => {
|
|
79
|
+
expect(isNetworkFetchError({error: {message: "Failed to fetch"}})).toBe(true);
|
|
80
|
+
expect(isNetworkFetchError({error: {message: "fetch failed"}})).toBe(true);
|
|
81
|
+
expect(isNetworkFetchError({error: {message: "Network Error"}})).toBe(true);
|
|
82
|
+
expect(isNetworkFetchError({error: {message: "Network unavailable"}})).toBe(true);
|
|
83
|
+
expect(isNetworkFetchError({error: {message: "Load failed"}})).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns true for string error fields", () => {
|
|
87
|
+
expect(isNetworkFetchError({error: "fetch failed"})).toBe(true);
|
|
88
|
+
expect(isNetworkFetchError({payload: {error: "network error"}})).toBe(true);
|
|
89
|
+
expect(isNetworkFetchError({error: "Failed to fetch", status: "FETCH_ERROR"})).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns false for non-network errors", () => {
|
|
93
|
+
expect(isNetworkFetchError({error: "Unauthorized", status: "FETCH_ERROR"})).toBe(false);
|
|
94
|
+
expect(isNetworkFetchError({error: {message: "Unauthorized"}})).toBe(false);
|
|
95
|
+
expect(isNetworkFetchError(null)).toBe(false);
|
|
96
|
+
expect(isNetworkFetchError(undefined)).toBe(false);
|
|
97
|
+
expect(isNetworkFetchError({})).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("shouldReplayQueuedMutation", () => {
|
|
102
|
+
const baseMutation: QueuedMutation = {
|
|
103
|
+
args: {body: {title: "Test"}},
|
|
104
|
+
endpointName: "postTodos",
|
|
105
|
+
id: "m1",
|
|
106
|
+
timestamp: "2026-04-15T10:00:00.000Z",
|
|
107
|
+
type: "create",
|
|
108
|
+
userId: "user-a",
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
it("replays only mutations owned by the current user", () => {
|
|
112
|
+
expect(shouldReplayQueuedMutation(baseMutation, "user-a")).toBe(true);
|
|
113
|
+
expect(shouldReplayQueuedMutation(baseMutation, "user-b")).toBe(false);
|
|
114
|
+
expect(shouldReplayQueuedMutation(baseMutation, undefined)).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("does not replay legacy mutations without userId", () => {
|
|
118
|
+
const legacy = {...baseMutation, userId: undefined};
|
|
119
|
+
expect(shouldReplayQueuedMutation(legacy, "user-a")).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("buildOptimisticCreateItem", () => {
|
|
124
|
+
const mutation: QueuedMutation = {
|
|
125
|
+
args: {body: {title: "New"}},
|
|
126
|
+
endpointName: "postTodos",
|
|
127
|
+
id: "queue-1",
|
|
128
|
+
timestamp: "2026-04-15T10:00:00.000Z",
|
|
129
|
+
type: "create",
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
it("applies temp ids after body spread so client ids cannot win", () => {
|
|
133
|
+
const item = buildOptimisticCreateItem(mutation, {
|
|
134
|
+
_id: "client-id",
|
|
135
|
+
id: "client-id",
|
|
136
|
+
title: "New",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(item._id).toBe("temp-queue-1");
|
|
140
|
+
expect(item.id).toBe("temp-queue-1");
|
|
141
|
+
expect(item.title).toBe("New");
|
|
142
|
+
expect(item.created).toBe(mutation.timestamp);
|
|
143
|
+
expect(item.updated).toBe(mutation.timestamp);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("offlineMiddleware", () => {
|
|
148
|
+
it("uses list-cache updated timestamp for queued update conflict headers", async () => {
|
|
149
|
+
const api = createTestApi();
|
|
150
|
+
const offline = createOfflineMiddleware({
|
|
151
|
+
api,
|
|
152
|
+
endpoints: ["patchTodosById"],
|
|
153
|
+
});
|
|
154
|
+
const store = configureStore({
|
|
155
|
+
middleware: (getDefaultMiddleware) =>
|
|
156
|
+
getDefaultMiddleware({serializableCheck: false}).concat(offline.middleware),
|
|
157
|
+
reducer: {
|
|
158
|
+
auth: (state = {userId: "user-a"}) => state,
|
|
159
|
+
offline: offline.offlineReducer,
|
|
160
|
+
testApi: testApiReducer,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const rejectedMutation: UnknownAction = {
|
|
165
|
+
error: {message: "Network unavailable"},
|
|
166
|
+
meta: {
|
|
167
|
+
arg: {
|
|
168
|
+
endpointName: "patchTodosById",
|
|
169
|
+
originalArgs: {
|
|
170
|
+
body: {title: "Queued title"},
|
|
171
|
+
id: "todo-1",
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
type: "testApi/executeMutation/rejected",
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
store.dispatch(rejectedMutation);
|
|
179
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
180
|
+
|
|
181
|
+
const queue = selectOfflineQueue(store.getState());
|
|
182
|
+
expect(queue).toHaveLength(1);
|
|
183
|
+
expect(queue[0].baseUpdatedAt).toBe(LIST_UPDATED_AT);
|
|
184
|
+
expect(queue[0].timestamp).toBe(LIST_UPDATED_AT);
|
|
185
|
+
expect(queue[0].userId).toBe("user-a");
|
|
186
|
+
});
|
|
187
|
+
});
|