@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,696 @@
|
|
|
1
|
+
import {beforeEach, describe, expect, it, mock} from "bun:test";
|
|
2
|
+
|
|
3
|
+
mock.module("react-native", () => ({
|
|
4
|
+
Platform: {OS: "web"},
|
|
5
|
+
StyleSheet: {create: (styles: unknown) => styles},
|
|
6
|
+
}));
|
|
7
|
+
mock.module("../platform", () => ({IsWeb: true}));
|
|
8
|
+
|
|
9
|
+
let authToken: string | null = null;
|
|
10
|
+
let currentUserId: string | undefined = "test-user";
|
|
11
|
+
|
|
12
|
+
mock.module("../authSlice", () => ({
|
|
13
|
+
getAuthToken: async () => authToken,
|
|
14
|
+
selectCurrentUserId: (state: {auth?: {userId?: string}}) => state.auth?.userId,
|
|
15
|
+
}));
|
|
16
|
+
mock.module("../constants", () => ({
|
|
17
|
+
baseUrl: "http://localhost:4000",
|
|
18
|
+
LOGOUT_ACTION_TYPE: "auth/logout",
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const {configureStore} = await import("@reduxjs/toolkit");
|
|
22
|
+
const {createApi, fetchBaseQuery} = await import("@reduxjs/toolkit/query");
|
|
23
|
+
const {createOfflineMiddleware} = await import("../offlineMiddleware");
|
|
24
|
+
const {selectConflicts, selectIsOnline, selectIsSyncing, selectOfflineQueue, setOnlineStatus} =
|
|
25
|
+
await import("../offlineSlice");
|
|
26
|
+
|
|
27
|
+
interface TodoRecord {
|
|
28
|
+
_id?: string;
|
|
29
|
+
id?: string;
|
|
30
|
+
title: string;
|
|
31
|
+
completed?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ListResponse {
|
|
35
|
+
data: TodoRecord[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type TestStore = ReturnType<typeof createTestStore>;
|
|
39
|
+
|
|
40
|
+
const createResponse = ({
|
|
41
|
+
data = {data: {_id: "123", title: "Synced"}},
|
|
42
|
+
status = 200,
|
|
43
|
+
}: {
|
|
44
|
+
data?: unknown;
|
|
45
|
+
status?: number;
|
|
46
|
+
} = {}) => ({
|
|
47
|
+
json: () => Promise.resolve(data),
|
|
48
|
+
status,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const mockFetch = mock(() => Promise.resolve(createResponse()));
|
|
52
|
+
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
|
53
|
+
|
|
54
|
+
const api = createApi({
|
|
55
|
+
baseQuery: fetchBaseQuery({baseUrl: "http://localhost:4000"}),
|
|
56
|
+
endpoints: (builder) => ({
|
|
57
|
+
deleteTodosById: builder.mutation({
|
|
58
|
+
query: (args: {id: string}) => ({
|
|
59
|
+
method: "DELETE",
|
|
60
|
+
url: `/todos/${args.id}`,
|
|
61
|
+
}),
|
|
62
|
+
}),
|
|
63
|
+
getPutTodos: builder.query<ListResponse, Record<string, unknown> | undefined>({
|
|
64
|
+
query: () => "/todos",
|
|
65
|
+
}),
|
|
66
|
+
getTodos: builder.query<ListResponse, Record<string, unknown> | undefined>({
|
|
67
|
+
query: () => "/todos",
|
|
68
|
+
}),
|
|
69
|
+
getTodosById: builder.query<{data: TodoRecord}, {id: string}>({
|
|
70
|
+
query: ({id}) => `/todos/${id}`,
|
|
71
|
+
}),
|
|
72
|
+
patchTodosById: builder.mutation({
|
|
73
|
+
query: (args: {id: string; body: Record<string, unknown>}) => ({
|
|
74
|
+
body: args.body,
|
|
75
|
+
method: "PATCH",
|
|
76
|
+
url: `/todos/${args.id}`,
|
|
77
|
+
}),
|
|
78
|
+
}),
|
|
79
|
+
postTodos: builder.mutation({
|
|
80
|
+
query: (args: {body: Record<string, unknown>}) => ({
|
|
81
|
+
body: args.body,
|
|
82
|
+
method: "POST",
|
|
83
|
+
url: "/todos",
|
|
84
|
+
}),
|
|
85
|
+
}),
|
|
86
|
+
}),
|
|
87
|
+
reducerPath: "terreno-rtk",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const createTestStore = (endpoints = ["postTodos", "patchTodosById", "deleteTodosById"]) => {
|
|
91
|
+
const offline = createOfflineMiddleware({
|
|
92
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic API type is intentionally broad.
|
|
93
|
+
api: api as any,
|
|
94
|
+
endpoints,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return configureStore({
|
|
98
|
+
middleware: (getDefault) =>
|
|
99
|
+
getDefault({serializableCheck: false}).concat(api.middleware, offline.middleware),
|
|
100
|
+
reducer: {
|
|
101
|
+
auth: (state = {userId: currentUserId}) => state,
|
|
102
|
+
[api.reducerPath]: api.reducer,
|
|
103
|
+
offline: offline.offlineReducer,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const waitForEffects = async (ms = 0): Promise<void> => {
|
|
109
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const queueRejectedMutation = (
|
|
113
|
+
store: TestStore,
|
|
114
|
+
{
|
|
115
|
+
endpointName = "patchTodosById",
|
|
116
|
+
error = {message: "fetch failed"},
|
|
117
|
+
originalArgs = {body: {title: "Updated"}, id: "123"},
|
|
118
|
+
payload = {error: "Network error", status: "FETCH_ERROR"},
|
|
119
|
+
}: {
|
|
120
|
+
endpointName?: string;
|
|
121
|
+
error?: Record<string, unknown>;
|
|
122
|
+
originalArgs?: unknown;
|
|
123
|
+
payload?: unknown;
|
|
124
|
+
} = {}
|
|
125
|
+
): void => {
|
|
126
|
+
store.dispatch({
|
|
127
|
+
error,
|
|
128
|
+
meta: {
|
|
129
|
+
arg: {
|
|
130
|
+
endpointName,
|
|
131
|
+
originalArgs,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
payload,
|
|
135
|
+
type: "terreno-rtk/executeMutation/rejected",
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const goOfflineAndQueue = (
|
|
140
|
+
store: TestStore,
|
|
141
|
+
mutation?: Parameters<typeof queueRejectedMutation>[1]
|
|
142
|
+
): void => {
|
|
143
|
+
store.dispatch(setOnlineStatus(false));
|
|
144
|
+
queueRejectedMutation(store, mutation);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const syncQueuedMutations = async (store: TestStore, ms = 120): Promise<void> => {
|
|
148
|
+
store.dispatch(setOnlineStatus(true));
|
|
149
|
+
await waitForEffects(ms);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const getFetchCall = (index = 0): [string, RequestInit] =>
|
|
153
|
+
mockFetch.mock.calls[index] as unknown as [string, RequestInit];
|
|
154
|
+
|
|
155
|
+
const seedTodosCache = async (
|
|
156
|
+
store: TestStore,
|
|
157
|
+
args: Record<string, unknown> | undefined,
|
|
158
|
+
todos: TodoRecord[]
|
|
159
|
+
): Promise<void> => {
|
|
160
|
+
store.dispatch(api.util.upsertQueryData("getTodos", args, {data: todos}));
|
|
161
|
+
await waitForEffects();
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const seedTodoByIdCache = async (
|
|
165
|
+
store: TestStore,
|
|
166
|
+
id: string,
|
|
167
|
+
todo: TodoRecord & {updated: string}
|
|
168
|
+
): Promise<void> => {
|
|
169
|
+
store.dispatch(api.util.upsertQueryData("getTodosById", {id}, {data: todo}));
|
|
170
|
+
await waitForEffects();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const getCachedTodos = (
|
|
174
|
+
store: TestStore,
|
|
175
|
+
args: Record<string, unknown> | undefined
|
|
176
|
+
): TodoRecord[] =>
|
|
177
|
+
api.endpoints.getTodos.select(args)(store.getState() as unknown as never).data?.data ?? [];
|
|
178
|
+
|
|
179
|
+
describe("createOfflineMiddleware", () => {
|
|
180
|
+
let store: TestStore;
|
|
181
|
+
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
currentUserId = "test-user";
|
|
184
|
+
store = createTestStore();
|
|
185
|
+
authToken = null;
|
|
186
|
+
mockFetch.mockReset();
|
|
187
|
+
mockFetch.mockImplementation(() => Promise.resolve(createResponse()));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("network monitoring", () => {
|
|
191
|
+
it("responds to explicit online and offline status changes", () => {
|
|
192
|
+
store.dispatch(setOnlineStatus(false));
|
|
193
|
+
expect(selectIsOnline(store.getState())).toBe(false);
|
|
194
|
+
|
|
195
|
+
store.dispatch(setOnlineStatus(true));
|
|
196
|
+
expect(selectIsOnline(store.getState())).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("does not set up web event listeners (web uses useServerStatus)", async () => {
|
|
200
|
+
const addedEvents: string[] = [];
|
|
201
|
+
const globalWithDom = globalThis as unknown as {navigator?: unknown; window?: unknown};
|
|
202
|
+
const originalWindow = globalWithDom.window;
|
|
203
|
+
|
|
204
|
+
Object.defineProperty(globalThis, "window", {
|
|
205
|
+
configurable: true,
|
|
206
|
+
value: {
|
|
207
|
+
addEventListener: (event: string) => {
|
|
208
|
+
addedEvents.push(event);
|
|
209
|
+
},
|
|
210
|
+
removeEventListener: () => {},
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const webStore = createTestStore();
|
|
216
|
+
webStore.dispatch({type: "init-network-monitoring"});
|
|
217
|
+
await waitForEffects();
|
|
218
|
+
|
|
219
|
+
expect(addedEvents).not.toContain("online");
|
|
220
|
+
expect(addedEvents).not.toContain("offline");
|
|
221
|
+
} finally {
|
|
222
|
+
Object.defineProperty(globalThis, "window", {
|
|
223
|
+
configurable: true,
|
|
224
|
+
value: originalWindow,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("mutation queueing", () => {
|
|
231
|
+
it("queues a configured mutation when offline and a FETCH_ERROR arrives", () => {
|
|
232
|
+
goOfflineAndQueue(store);
|
|
233
|
+
|
|
234
|
+
const queue = selectOfflineQueue(store.getState());
|
|
235
|
+
expect(queue).toHaveLength(1);
|
|
236
|
+
expect(queue[0].endpointName).toBe("patchTodosById");
|
|
237
|
+
expect(queue[0].type).toBe("update");
|
|
238
|
+
expect(queue[0].args).toEqual({body: {title: "Updated"}, id: "123"});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("queues mutations on network errors even when the browser reports online", () => {
|
|
242
|
+
store.dispatch(setOnlineStatus(true));
|
|
243
|
+
queueRejectedMutation(store, {
|
|
244
|
+
error: {message: "Failed to fetch", name: "TypeError"},
|
|
245
|
+
payload: {error: "TypeError: Failed to fetch", status: "FETCH_ERROR"},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(1);
|
|
249
|
+
expect(selectIsOnline(store.getState())).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("does not queue auth-related FETCH_ERROR responses when online", () => {
|
|
253
|
+
queueRejectedMutation(store, {
|
|
254
|
+
error: {message: "No token found for postTodos"},
|
|
255
|
+
payload: {error: "No token found for postTodos", status: "FETCH_ERROR"},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("does not queue non-network errors when online", () => {
|
|
262
|
+
queueRejectedMutation(store, {
|
|
263
|
+
error: {message: "Validation error"},
|
|
264
|
+
payload: {data: {message: "Validation error"}, status: 400},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(0);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("marks offline when a query fails with a network error while online", () => {
|
|
271
|
+
store.dispatch(setOnlineStatus(true));
|
|
272
|
+
store.dispatch({
|
|
273
|
+
error: {message: "Failed to fetch", name: "TypeError"},
|
|
274
|
+
meta: {arg: {endpointName: "getTodos", originalArgs: {}}},
|
|
275
|
+
payload: {error: "TypeError: Failed to fetch", status: "FETCH_ERROR"},
|
|
276
|
+
type: "terreno-rtk/executeQuery/rejected",
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(selectIsOnline(store.getState())).toBe(false);
|
|
280
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(0);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("does not queue unconfigured endpoint failures", () => {
|
|
284
|
+
store.dispatch(setOnlineStatus(false));
|
|
285
|
+
queueRejectedMutation(store, {
|
|
286
|
+
endpointName: "someOtherMutation",
|
|
287
|
+
originalArgs: {foo: "bar"},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("does not queue non-network errors while offline", () => {
|
|
294
|
+
store.dispatch(setOnlineStatus(false));
|
|
295
|
+
queueRejectedMutation(store, {
|
|
296
|
+
error: {message: "Validation error"},
|
|
297
|
+
payload: {data: {message: "Validation error"}, status: 400},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(0);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("recognizes fetch, network, and TypeError failure shapes", () => {
|
|
304
|
+
store.dispatch(setOnlineStatus(false));
|
|
305
|
+
queueRejectedMutation(store, {error: {message: "fetch failed"}});
|
|
306
|
+
queueRejectedMutation(store, {error: {message: "network unavailable"}});
|
|
307
|
+
queueRejectedMutation(store, {error: {name: "TypeError"}});
|
|
308
|
+
|
|
309
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(3);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("infers create, update, delete, and default update mutation types", () => {
|
|
313
|
+
store.dispatch(setOnlineStatus(false));
|
|
314
|
+
queueRejectedMutation(store, {
|
|
315
|
+
endpointName: "postTodos",
|
|
316
|
+
originalArgs: {body: {title: "New"}},
|
|
317
|
+
});
|
|
318
|
+
queueRejectedMutation(store, {
|
|
319
|
+
endpointName: "patchTodosById",
|
|
320
|
+
originalArgs: {body: {title: "Updated"}, id: "123"},
|
|
321
|
+
});
|
|
322
|
+
queueRejectedMutation(store, {
|
|
323
|
+
endpointName: "deleteTodosById",
|
|
324
|
+
originalArgs: {id: "123"},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const queue = selectOfflineQueue(store.getState());
|
|
328
|
+
expect(queue.map((mutation) => mutation.type)).toEqual(["create", "update", "delete"]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("defaults configured non-standard mutation names to update", () => {
|
|
332
|
+
const putStore = createTestStore(["putTodos"]);
|
|
333
|
+
putStore.dispatch(setOnlineStatus(false));
|
|
334
|
+
queueRejectedMutation(putStore, {
|
|
335
|
+
endpointName: "putTodos",
|
|
336
|
+
originalArgs: {body: {title: "Put update"}, id: "123"},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(selectOfflineQueue(putStore.getState())[0].type).toBe("update");
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe("optimistic cache updates", () => {
|
|
344
|
+
it("adds optimistic creates to every active list cache entry", async () => {
|
|
345
|
+
await seedTodosCache(store, {}, [{id: "1", title: "Existing"}]);
|
|
346
|
+
await seedTodosCache(store, {completed: false}, [{id: "2", title: "Filtered"}]);
|
|
347
|
+
|
|
348
|
+
goOfflineAndQueue(store, {
|
|
349
|
+
endpointName: "postTodos",
|
|
350
|
+
originalArgs: {body: {completed: false, title: "Offline new"}},
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const allTodos = getCachedTodos(store, {});
|
|
354
|
+
const filteredTodos = getCachedTodos(store, {completed: false});
|
|
355
|
+
expect(allTodos[0]).toMatchObject({completed: false, title: "Offline new"});
|
|
356
|
+
expect(allTodos[0].id).toStartWith("temp-postTodos-");
|
|
357
|
+
expect(filteredTodos[0]).toMatchObject({completed: false, title: "Offline new"});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("updates optimistic changes by either id or _id across all list cache entries", async () => {
|
|
361
|
+
await seedTodosCache(store, {}, [
|
|
362
|
+
{_id: "mongo-id", title: "By mongo id"},
|
|
363
|
+
{id: "plain-id", title: "By plain id"},
|
|
364
|
+
]);
|
|
365
|
+
|
|
366
|
+
goOfflineAndQueue(store, {
|
|
367
|
+
originalArgs: {body: {completed: true, title: "Updated mongo"}, id: "mongo-id"},
|
|
368
|
+
});
|
|
369
|
+
queueRejectedMutation(store, {
|
|
370
|
+
originalArgs: {body: {completed: true, title: "Updated plain"}, id: "plain-id"},
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(getCachedTodos(store, {})).toEqual([
|
|
374
|
+
{_id: "mongo-id", completed: true, title: "Updated mongo"},
|
|
375
|
+
{completed: true, id: "plain-id", title: "Updated plain"},
|
|
376
|
+
]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("ignores optimistic updates for missing ids or bodies without changing cached lists", async () => {
|
|
380
|
+
const originalTodos = [{id: "1", title: "Existing"}];
|
|
381
|
+
await seedTodosCache(store, {}, originalTodos);
|
|
382
|
+
|
|
383
|
+
store.dispatch(setOnlineStatus(false));
|
|
384
|
+
queueRejectedMutation(store, {originalArgs: {body: {title: "No id"}}});
|
|
385
|
+
queueRejectedMutation(store, {originalArgs: {id: "1"}});
|
|
386
|
+
|
|
387
|
+
expect(getCachedTodos(store, {})).toEqual(originalTodos);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("removes optimistic deletes from every active list cache entry", async () => {
|
|
391
|
+
await seedTodosCache(store, {}, [
|
|
392
|
+
{id: "1", title: "Keep"},
|
|
393
|
+
{_id: "2", title: "Delete by _id"},
|
|
394
|
+
{id: "3", title: "Delete by id"},
|
|
395
|
+
]);
|
|
396
|
+
|
|
397
|
+
goOfflineAndQueue(store, {
|
|
398
|
+
endpointName: "deleteTodosById",
|
|
399
|
+
originalArgs: {id: "2"},
|
|
400
|
+
});
|
|
401
|
+
queueRejectedMutation(store, {
|
|
402
|
+
endpointName: "deleteTodosById",
|
|
403
|
+
originalArgs: {id: "3"},
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
expect(getCachedTodos(store, {})).toEqual([{id: "1", title: "Keep"}]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("does not throw when no matching list cache is active", () => {
|
|
410
|
+
expect(() =>
|
|
411
|
+
goOfflineAndQueue(store, {
|
|
412
|
+
endpointName: "postTodos",
|
|
413
|
+
originalArgs: {body: {title: "No cache"}},
|
|
414
|
+
})
|
|
415
|
+
).not.toThrow();
|
|
416
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(1);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe("sync on reconnect", () => {
|
|
421
|
+
it("replays creates with POST, JSON body, auth header, and no conflict headers", async () => {
|
|
422
|
+
authToken = "auth-token";
|
|
423
|
+
goOfflineAndQueue(store, {
|
|
424
|
+
endpointName: "postTodos",
|
|
425
|
+
originalArgs: {body: {completed: false, title: "Created offline"}},
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
await syncQueuedMutations(store);
|
|
429
|
+
|
|
430
|
+
const [url, options] = getFetchCall();
|
|
431
|
+
const headers = options.headers as Record<string, string>;
|
|
432
|
+
expect(url).toBe("http://localhost:4000/todos");
|
|
433
|
+
expect(options.method).toBe("POST");
|
|
434
|
+
expect(options.body).toBe(JSON.stringify({completed: false, title: "Created offline"}));
|
|
435
|
+
expect(headers.authorization).toBe("Bearer auth-token");
|
|
436
|
+
expect(headers["If-Unmodified-Since"]).toBeUndefined();
|
|
437
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(0);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("replays updates with PATCH, JSON body, HTTP-date and precise timestamp headers", async () => {
|
|
441
|
+
await seedTodoByIdCache(store, "123", {
|
|
442
|
+
_id: "123",
|
|
443
|
+
title: "Original",
|
|
444
|
+
updated: "2025-06-15T12:00:00.000Z",
|
|
445
|
+
});
|
|
446
|
+
goOfflineAndQueue(store, {
|
|
447
|
+
endpointName: "patchTodosById",
|
|
448
|
+
originalArgs: {body: {title: "Updated offline"}, id: "123"},
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
await syncQueuedMutations(store);
|
|
452
|
+
|
|
453
|
+
const [url, options] = getFetchCall();
|
|
454
|
+
const headers = options.headers as Record<string, string>;
|
|
455
|
+
expect(url).toBe("http://localhost:4000/todos/123");
|
|
456
|
+
expect(options.method).toBe("PATCH");
|
|
457
|
+
expect(options.body).toBe(JSON.stringify({title: "Updated offline"}));
|
|
458
|
+
expect(headers["If-Unmodified-Since"]).toContain("GMT");
|
|
459
|
+
expect(new Date(headers["X-Unmodified-Since-ISO"]).toISOString()).toBe(
|
|
460
|
+
headers["X-Unmodified-Since-ISO"]
|
|
461
|
+
);
|
|
462
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(0);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("replays deletes with DELETE and handles 204 without parsing JSON", async () => {
|
|
466
|
+
mockFetch.mockImplementationOnce(() => Promise.resolve(createResponse({status: 204})));
|
|
467
|
+
goOfflineAndQueue(store, {
|
|
468
|
+
endpointName: "deleteTodosById",
|
|
469
|
+
originalArgs: {id: "123"},
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
await syncQueuedMutations(store);
|
|
473
|
+
|
|
474
|
+
const [url, options] = getFetchCall();
|
|
475
|
+
expect(url).toBe("http://localhost:4000/todos/123");
|
|
476
|
+
expect(options.method).toBe("DELETE");
|
|
477
|
+
expect(options.body).toBeUndefined();
|
|
478
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(0);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("does not replay when coming online with an empty queue", async () => {
|
|
482
|
+
store.dispatch(setOnlineStatus(false));
|
|
483
|
+
await syncQueuedMutations(store, 50);
|
|
484
|
+
|
|
485
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
486
|
+
expect(selectIsSyncing(store.getState())).toBe(false);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("does not replay while staying offline", async () => {
|
|
490
|
+
goOfflineAndQueue(store);
|
|
491
|
+
store.dispatch(setOnlineStatus(false));
|
|
492
|
+
await waitForEffects(50);
|
|
493
|
+
|
|
494
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
495
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(1);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("replays multiple mutations in FIFO order", async () => {
|
|
499
|
+
goOfflineAndQueue(store, {
|
|
500
|
+
endpointName: "postTodos",
|
|
501
|
+
originalArgs: {body: {title: "First"}},
|
|
502
|
+
});
|
|
503
|
+
queueRejectedMutation(store, {
|
|
504
|
+
endpointName: "patchTodosById",
|
|
505
|
+
originalArgs: {body: {completed: true}, id: "456"},
|
|
506
|
+
});
|
|
507
|
+
queueRejectedMutation(store, {
|
|
508
|
+
endpointName: "deleteTodosById",
|
|
509
|
+
originalArgs: {id: "789"},
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
await syncQueuedMutations(store);
|
|
513
|
+
|
|
514
|
+
expect(
|
|
515
|
+
mockFetch.mock.calls.map((call) =>
|
|
516
|
+
((call as unknown[])[0] as string).replace(baseUrlForTest, "")
|
|
517
|
+
)
|
|
518
|
+
).toEqual(["/todos", "/todos/456", "/todos/789"]);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it("sets syncing during replay and clears it after success", async () => {
|
|
522
|
+
mockFetch.mockImplementationOnce(
|
|
523
|
+
() => new Promise((resolve) => setTimeout(() => resolve(createResponse({status: 200})), 50))
|
|
524
|
+
);
|
|
525
|
+
goOfflineAndQueue(store, {
|
|
526
|
+
endpointName: "postTodos",
|
|
527
|
+
originalArgs: {body: {title: "New"}},
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
store.dispatch(setOnlineStatus(true));
|
|
531
|
+
await waitForEffects(10);
|
|
532
|
+
expect(selectIsSyncing(store.getState())).toBe(true);
|
|
533
|
+
|
|
534
|
+
await waitForEffects(100);
|
|
535
|
+
expect(selectIsSyncing(store.getState())).toBe(false);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("creates a conflict record from wrapped 409 server data and dequeues the mutation", async () => {
|
|
539
|
+
mockFetch.mockImplementationOnce(() =>
|
|
540
|
+
Promise.resolve(
|
|
541
|
+
createResponse({
|
|
542
|
+
data: {
|
|
543
|
+
data: {_id: "123", completed: false, title: "Remote version"},
|
|
544
|
+
error: "Conflict",
|
|
545
|
+
message: "Document was modified since your last read",
|
|
546
|
+
},
|
|
547
|
+
status: 409,
|
|
548
|
+
})
|
|
549
|
+
)
|
|
550
|
+
);
|
|
551
|
+
goOfflineAndQueue(store, {
|
|
552
|
+
originalArgs: {body: {completed: true}, id: "123"},
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
await syncQueuedMutations(store);
|
|
556
|
+
|
|
557
|
+
const conflicts = selectConflicts(store.getState());
|
|
558
|
+
expect(conflicts).toHaveLength(1);
|
|
559
|
+
expect(conflicts[0]).toMatchObject({
|
|
560
|
+
args: {body: {completed: true}, id: "123"},
|
|
561
|
+
dismissed: false,
|
|
562
|
+
endpointName: "patchTodosById",
|
|
563
|
+
serverDocument: {_id: "123", completed: false, title: "Remote version"},
|
|
564
|
+
});
|
|
565
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(0);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it("creates a conflict record from raw 409 data when the server does not wrap data", async () => {
|
|
569
|
+
mockFetch.mockImplementationOnce(() =>
|
|
570
|
+
Promise.resolve(createResponse({data: {_id: "123", title: "Raw remote"}, status: 409}))
|
|
571
|
+
);
|
|
572
|
+
goOfflineAndQueue(store);
|
|
573
|
+
|
|
574
|
+
await syncQueuedMutations(store);
|
|
575
|
+
|
|
576
|
+
expect(selectConflicts(store.getState())[0].serverDocument).toEqual({
|
|
577
|
+
_id: "123",
|
|
578
|
+
title: "Raw remote",
|
|
579
|
+
});
|
|
580
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(0);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("continues replaying after a conflict so later local changes still sync", async () => {
|
|
584
|
+
mockFetch
|
|
585
|
+
.mockImplementationOnce(() =>
|
|
586
|
+
Promise.resolve(createResponse({data: {data: {_id: "1", title: "Remote"}}, status: 409}))
|
|
587
|
+
)
|
|
588
|
+
.mockImplementationOnce(() => Promise.resolve(createResponse()));
|
|
589
|
+
goOfflineAndQueue(store, {
|
|
590
|
+
originalArgs: {body: {title: "Conflicting local"}, id: "1"},
|
|
591
|
+
});
|
|
592
|
+
queueRejectedMutation(store, {
|
|
593
|
+
originalArgs: {body: {title: "Later local"}, id: "2"},
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
await syncQueuedMutations(store);
|
|
597
|
+
|
|
598
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
599
|
+
expect(selectConflicts(store.getState())).toHaveLength(1);
|
|
600
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(0);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("dequeues permanent non-conflict 4xx failures to avoid infinite retry loops", async () => {
|
|
604
|
+
mockFetch.mockImplementationOnce(() =>
|
|
605
|
+
Promise.resolve(createResponse({data: {message: "Forbidden"}, status: 403}))
|
|
606
|
+
);
|
|
607
|
+
goOfflineAndQueue(store);
|
|
608
|
+
|
|
609
|
+
await syncQueuedMutations(store);
|
|
610
|
+
|
|
611
|
+
expect(selectConflicts(store.getState())).toHaveLength(0);
|
|
612
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(0);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("leaves 5xx failures queued for a future reconnect", async () => {
|
|
616
|
+
mockFetch.mockImplementationOnce(() =>
|
|
617
|
+
Promise.resolve(createResponse({data: {message: "Server error"}, status: 500}))
|
|
618
|
+
);
|
|
619
|
+
goOfflineAndQueue(store);
|
|
620
|
+
|
|
621
|
+
await syncQueuedMutations(store);
|
|
622
|
+
|
|
623
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(1);
|
|
624
|
+
expect(selectIsSyncing(store.getState())).toBe(false);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it("stops replay after a thrown network error and preserves later mutations", async () => {
|
|
628
|
+
mockFetch
|
|
629
|
+
.mockImplementationOnce(() => Promise.reject(new Error("network down")))
|
|
630
|
+
.mockImplementationOnce(() => Promise.resolve(createResponse()));
|
|
631
|
+
goOfflineAndQueue(store, {
|
|
632
|
+
originalArgs: {body: {title: "First"}, id: "1"},
|
|
633
|
+
});
|
|
634
|
+
queueRejectedMutation(store, {
|
|
635
|
+
originalArgs: {body: {title: "Second"}, id: "2"},
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
await syncQueuedMutations(store);
|
|
639
|
+
|
|
640
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
641
|
+
expect(selectOfflineQueue(store.getState()).map((mutation) => mutation.args)).toEqual([
|
|
642
|
+
{body: {title: "First"}, id: "1"},
|
|
643
|
+
{body: {title: "Second"}, id: "2"},
|
|
644
|
+
]);
|
|
645
|
+
expect(selectIsSyncing(store.getState())).toBe(false);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("retries preserved mutations on a later online event after a transient failure", async () => {
|
|
649
|
+
mockFetch
|
|
650
|
+
.mockImplementationOnce(() => Promise.reject(new Error("network down")))
|
|
651
|
+
.mockImplementation(() => Promise.resolve(createResponse()));
|
|
652
|
+
goOfflineAndQueue(store);
|
|
653
|
+
|
|
654
|
+
await syncQueuedMutations(store);
|
|
655
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(1);
|
|
656
|
+
|
|
657
|
+
store.dispatch(setOnlineStatus(false));
|
|
658
|
+
await syncQueuedMutations(store);
|
|
659
|
+
|
|
660
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
661
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(0);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("does not start concurrent replay for repeated online events", async () => {
|
|
665
|
+
mockFetch.mockImplementationOnce(
|
|
666
|
+
() => new Promise((resolve) => setTimeout(() => resolve(createResponse({status: 200})), 50))
|
|
667
|
+
);
|
|
668
|
+
goOfflineAndQueue(store);
|
|
669
|
+
|
|
670
|
+
store.dispatch(setOnlineStatus(true));
|
|
671
|
+
store.dispatch(setOnlineStatus(true));
|
|
672
|
+
store.dispatch(setOnlineStatus(true));
|
|
673
|
+
await waitForEffects(120);
|
|
674
|
+
|
|
675
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
676
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(0);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("handles response JSON parse failures like transient replay failures", async () => {
|
|
680
|
+
mockFetch.mockImplementationOnce(() =>
|
|
681
|
+
Promise.resolve({
|
|
682
|
+
json: () => Promise.reject(new Error("invalid json")),
|
|
683
|
+
status: 502,
|
|
684
|
+
})
|
|
685
|
+
);
|
|
686
|
+
goOfflineAndQueue(store);
|
|
687
|
+
|
|
688
|
+
await syncQueuedMutations(store);
|
|
689
|
+
|
|
690
|
+
expect(selectOfflineQueue(store.getState())).toHaveLength(1);
|
|
691
|
+
expect(selectIsSyncing(store.getState())).toBe(false);
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
const baseUrlForTest = "http://localhost:4000";
|