@terreno/rtk 0.24.0 → 0.26.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/.ai/guidelines/core.md +2 -0
- package/dist/authSlice.d.ts.map +1 -1
- package/dist/authSlice.js +1 -1
- package/dist/authSlice.js.map +1 -1
- package/dist/authSlice.test.js +11 -7
- package/dist/authSlice.test.js.map +1 -1
- package/dist/betterAuthSlice.d.ts +9 -5
- package/dist/betterAuthSlice.d.ts.map +1 -1
- package/dist/betterAuthSlice.js +7 -20
- package/dist/betterAuthSlice.js.map +1 -1
- package/dist/constants.d.ts +7 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js.map +1 -1
- package/dist/isolated/constants.test.d.ts +2 -0
- package/dist/isolated/constants.test.d.ts.map +1 -0
- package/dist/isolated/constants.test.js +65 -0
- package/dist/isolated/constants.test.js.map +1 -0
- package/dist/mongooseSlice.test.js +0 -1
- package/dist/mongooseSlice.test.js.map +1 -1
- package/dist/sync.test.d.ts +2 -0
- package/dist/sync.test.d.ts.map +1 -0
- package/dist/sync.test.js +298 -0
- package/dist/sync.test.js.map +1 -0
- package/dist/useFeatureFlags.d.ts.map +1 -1
- package/dist/useFeatureFlags.js +4 -3
- package/dist/useFeatureFlags.js.map +1 -1
- package/dist/useRealtimeDebug.test.d.ts +2 -0
- package/dist/useRealtimeDebug.test.d.ts.map +1 -0
- package/dist/useRealtimeDebug.test.js +131 -0
- package/dist/useRealtimeDebug.test.js.map +1 -0
- package/dist/useServerStatus.test.js +49 -0
- package/dist/useServerStatus.test.js.map +1 -1
- package/dist/useTerrenoFeatureFlags.test.js +15 -0
- package/dist/useTerrenoFeatureFlags.test.js.map +1 -1
- package/dist/useUpgradeCheck.test.d.ts +2 -0
- package/dist/useUpgradeCheck.test.d.ts.map +1 -0
- package/dist/useUpgradeCheck.test.js +174 -0
- package/dist/useUpgradeCheck.test.js.map +1 -0
- package/dist/useUpgradeCheckNative.test.d.ts +2 -0
- package/dist/useUpgradeCheckNative.test.d.ts.map +1 -0
- package/dist/useUpgradeCheckNative.test.js +90 -0
- package/dist/useUpgradeCheckNative.test.js.map +1 -0
- package/package.json +2 -2
- package/src/authSlice.test.ts +20 -12
- package/src/authSlice.ts +1 -1
- package/src/betterAuthSlice.ts +19 -18
- package/src/constants.ts +7 -2
- package/src/isolated/constants.test.ts +95 -0
- package/src/mongooseSlice.test.ts +1 -2
- package/src/sync.test.ts +391 -0
- package/src/useFeatureFlags.ts +4 -3
- package/src/useRealtimeDebug.test.ts +173 -0
- package/src/useServerStatus.test.ts +68 -0
- package/src/useTerrenoFeatureFlags.test.ts +25 -0
- package/src/useUpgradeCheck.test.ts +226 -0
- package/src/useUpgradeCheckNative.test.ts +124 -0
- package/dist/isolated/constants.isolated.d.ts +0 -2
- package/dist/isolated/constants.isolated.d.ts.map +0 -1
- package/dist/isolated/constants.isolated.js +0 -87
- package/dist/isolated/constants.isolated.js.map +0 -1
- package/src/isolated/constants.isolated.ts +0 -92
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated tests for constants.ts paths that require mock.module.
|
|
3
|
+
*
|
|
4
|
+
* The module is loaded in a subprocess so mock.module does not replace
|
|
5
|
+
* expo-constants for the rest of the package test process.
|
|
6
|
+
*/
|
|
7
|
+
import {describe, it} from "bun:test";
|
|
8
|
+
import {assert} from "chai";
|
|
9
|
+
|
|
10
|
+
interface ConstantsModuleResult {
|
|
11
|
+
authDebug: boolean;
|
|
12
|
+
debugCalls: unknown[][];
|
|
13
|
+
errorCalls: unknown[][];
|
|
14
|
+
infoCalls: unknown[][];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const runConstantsModule = async (
|
|
18
|
+
expoConstants: Record<string, unknown>
|
|
19
|
+
): Promise<ConstantsModuleResult> => {
|
|
20
|
+
const constantsUrl = new URL("../constants.ts", import.meta.url).href;
|
|
21
|
+
const script = `
|
|
22
|
+
import {mock} from "bun:test";
|
|
23
|
+
const output = console.log.bind(console);
|
|
24
|
+
const debugCalls = [];
|
|
25
|
+
const errorCalls = [];
|
|
26
|
+
const infoCalls = [];
|
|
27
|
+
console.debug = (...args) => debugCalls.push(args);
|
|
28
|
+
console.error = (...args) => errorCalls.push(args);
|
|
29
|
+
console.info = (...args) => infoCalls.push(args);
|
|
30
|
+
mock.module("expo-constants", () => ({default: ${JSON.stringify(expoConstants)}}));
|
|
31
|
+
const loaded = await import(${JSON.stringify(constantsUrl)});
|
|
32
|
+
loaded.logAuth("hello");
|
|
33
|
+
loaded.logSocket(undefined, "ws on");
|
|
34
|
+
output(JSON.stringify({
|
|
35
|
+
authDebug: loaded.AUTH_DEBUG,
|
|
36
|
+
debugCalls,
|
|
37
|
+
errorCalls,
|
|
38
|
+
infoCalls,
|
|
39
|
+
}));
|
|
40
|
+
`;
|
|
41
|
+
const child = Bun.spawn([process.execPath, "-e", script], {
|
|
42
|
+
cwd: import.meta.dir,
|
|
43
|
+
stderr: "pipe",
|
|
44
|
+
stdout: "pipe",
|
|
45
|
+
});
|
|
46
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
47
|
+
child.exited,
|
|
48
|
+
new Response(child.stdout).text(),
|
|
49
|
+
new Response(child.stderr).text(),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
assert.equal(exitCode, 0, stderr);
|
|
53
|
+
return JSON.parse(stdout.trim()) as ConstantsModuleResult;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe("expo tunnel warning", () => {
|
|
57
|
+
it("warns when expoGoConfig.debuggerHost contains exp.direct", async () => {
|
|
58
|
+
const result = await runConstantsModule({
|
|
59
|
+
expoConfig: {extra: {}},
|
|
60
|
+
expoGoConfig: {debuggerHost: "abc.exp.direct"},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
assert.isTrue(
|
|
64
|
+
result.errorCalls.some((args) =>
|
|
65
|
+
args.some((value) => String(value).includes("Expo Tunnel is not currently"))
|
|
66
|
+
)
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("AUTH_DEBUG enabled path", () => {
|
|
72
|
+
it("logs auth and websocket debug messages when enabled", async () => {
|
|
73
|
+
const result = await runConstantsModule({
|
|
74
|
+
expoConfig: {extra: {AUTH_DEBUG: "true", WEBSOCKETS_DEBUG: "true"}},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
assert.isTrue(result.authDebug);
|
|
78
|
+
assert.isTrue(
|
|
79
|
+
result.debugCalls.some((args) =>
|
|
80
|
+
args.some((value) => String(value).includes("AUTH_DEBUG is enabled"))
|
|
81
|
+
)
|
|
82
|
+
);
|
|
83
|
+
assert.isTrue(
|
|
84
|
+
result.debugCalls.some((args) =>
|
|
85
|
+
args.some((value) => String(value).includes("WEBSOCKETS_DEBUG is enabled"))
|
|
86
|
+
)
|
|
87
|
+
);
|
|
88
|
+
assert.isTrue(
|
|
89
|
+
result.debugCalls.some((args) => args.some((value) => String(value).includes("hello")))
|
|
90
|
+
);
|
|
91
|
+
assert.isTrue(
|
|
92
|
+
result.infoCalls.some((args) => args[0] === "[websocket]" && args[1] === "ws on")
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
|
|
2
1
|
import {describe, expect, it} from "bun:test";
|
|
3
2
|
|
|
4
3
|
import {type ListResponse, populateId} from "./mongooseSlice";
|
|
@@ -39,7 +38,7 @@ describe("populateId", () => {
|
|
|
39
38
|
|
|
40
39
|
it("handles sparse arrays without throwing", () => {
|
|
41
40
|
const sparse: ListResponse<{_id: string}> = {
|
|
42
|
-
data: [undefined as
|
|
41
|
+
data: [undefined as unknown as {_id: string}, {_id: "found"}],
|
|
43
42
|
};
|
|
44
43
|
expect(populateId("found", sparse)).toEqual({_id: "found"});
|
|
45
44
|
});
|
package/src/sync.test.ts
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import {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
|
+
import type {Socket} from "socket.io-client";
|
|
7
|
+
|
|
8
|
+
import type {RealtimeEvent} from "./realtime";
|
|
9
|
+
import {useSyncConnection} from "./sync";
|
|
10
|
+
|
|
11
|
+
const REDUCER_PATH = "testApi";
|
|
12
|
+
|
|
13
|
+
type SocketHandler = (arg?: unknown) => void;
|
|
14
|
+
|
|
15
|
+
interface FakeSocket {
|
|
16
|
+
connected: boolean;
|
|
17
|
+
emit: ReturnType<typeof mock>;
|
|
18
|
+
on: ReturnType<typeof mock>;
|
|
19
|
+
off: ReturnType<typeof mock>;
|
|
20
|
+
trigger: (event: string, arg?: unknown) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const createFakeSocket = (connected: boolean): FakeSocket => {
|
|
24
|
+
const handlers: Record<string, SocketHandler[]> = {};
|
|
25
|
+
return {
|
|
26
|
+
connected,
|
|
27
|
+
emit: mock(() => {}),
|
|
28
|
+
off: mock((event: string, cb: SocketHandler) => {
|
|
29
|
+
handlers[event] = (handlers[event] ?? []).filter((h) => h !== cb);
|
|
30
|
+
}),
|
|
31
|
+
on: mock((event: string, cb: SocketHandler) => {
|
|
32
|
+
handlers[event] = handlers[event] ?? [];
|
|
33
|
+
handlers[event].push(cb);
|
|
34
|
+
}),
|
|
35
|
+
trigger: (event: string, arg?: unknown) => {
|
|
36
|
+
for (const cb of handlers[event] ?? []) {
|
|
37
|
+
cb(arg);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
interface QueryEntry {
|
|
44
|
+
status: string;
|
|
45
|
+
endpointName: string;
|
|
46
|
+
originalArgs: unknown;
|
|
47
|
+
data?: Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const createApi = (queries: Record<string, QueryEntry> | null) => {
|
|
51
|
+
const dataByEndpoint: Record<string, Record<string, unknown>> = {};
|
|
52
|
+
if (queries) {
|
|
53
|
+
for (const key of Object.keys(queries)) {
|
|
54
|
+
const entry = queries[key];
|
|
55
|
+
if (entry.data) {
|
|
56
|
+
dataByEndpoint[entry.endpointName] = entry.data;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const invalidateTags = mock((tags: string[]) => ({payload: tags, type: "api/invalidateTags"}));
|
|
62
|
+
const updateQueryData = mock(
|
|
63
|
+
(endpointName: string, _args: unknown, recipe: (draft: Record<string, unknown>) => void) => {
|
|
64
|
+
const draft = dataByEndpoint[endpointName];
|
|
65
|
+
if (draft) {
|
|
66
|
+
recipe(draft);
|
|
67
|
+
}
|
|
68
|
+
return {type: "api/updateQueryData"};
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const api = {
|
|
73
|
+
reducerPath: REDUCER_PATH,
|
|
74
|
+
util: {invalidateTags, updateQueryData},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const store = configureStore({
|
|
78
|
+
middleware: (getDefaultMiddleware) =>
|
|
79
|
+
getDefaultMiddleware({immutableCheck: false, serializableCheck: false}),
|
|
80
|
+
reducer: {
|
|
81
|
+
[REDUCER_PATH]: (state = {queries: queries ?? undefined}) => state,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return {api, invalidateTags, store, updateQueryData};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const renderSync = (
|
|
89
|
+
socket: FakeSocket | null,
|
|
90
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only structural RTK Query api stub
|
|
91
|
+
api: any,
|
|
92
|
+
store: ReturnType<typeof configureStore>,
|
|
93
|
+
options: {tagTypes?: string[]; debug?: boolean} = {}
|
|
94
|
+
) => {
|
|
95
|
+
const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) =>
|
|
96
|
+
React.createElement(Provider, {children, store});
|
|
97
|
+
return renderHook(
|
|
98
|
+
() =>
|
|
99
|
+
useSyncConnection({
|
|
100
|
+
api,
|
|
101
|
+
debug: options.debug ?? true,
|
|
102
|
+
socket: socket as unknown as Socket,
|
|
103
|
+
tagTypes: options.tagTypes ?? ["todos"],
|
|
104
|
+
}),
|
|
105
|
+
{wrapper: Wrapper}
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const syncEvent = (overrides: Partial<RealtimeEvent>): RealtimeEvent =>
|
|
110
|
+
({
|
|
111
|
+
collection: "todos",
|
|
112
|
+
id: "1",
|
|
113
|
+
method: "update",
|
|
114
|
+
...overrides,
|
|
115
|
+
}) as RealtimeEvent;
|
|
116
|
+
|
|
117
|
+
describe("useSyncConnection", () => {
|
|
118
|
+
it("does nothing when there is no socket", () => {
|
|
119
|
+
const {store, api} = createApi(null);
|
|
120
|
+
const {result} = renderSync(null, api, store);
|
|
121
|
+
expect(result.current).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("subscribes to model rooms when the socket is already connected", () => {
|
|
125
|
+
const socket = createFakeSocket(true);
|
|
126
|
+
const {store, api} = createApi({});
|
|
127
|
+
const {unmount} = renderSync(socket, api, store, {tagTypes: ["todos", "users"]});
|
|
128
|
+
|
|
129
|
+
expect(socket.emit).toHaveBeenCalledWith("subscribe:model", "todos");
|
|
130
|
+
expect(socket.emit).toHaveBeenCalledWith("subscribe:model", "users");
|
|
131
|
+
expect(socket.on).toHaveBeenCalledWith("connect", expect.any(Function));
|
|
132
|
+
expect(socket.on).toHaveBeenCalledWith("sync", expect.any(Function));
|
|
133
|
+
|
|
134
|
+
unmount();
|
|
135
|
+
expect(socket.emit).toHaveBeenCalledWith("unsubscribe:model", "todos");
|
|
136
|
+
expect(socket.off).toHaveBeenCalledWith("sync", expect.any(Function));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("subscribes on the connect event when not initially connected", () => {
|
|
140
|
+
const socket = createFakeSocket(false);
|
|
141
|
+
const {store, api} = createApi({});
|
|
142
|
+
const {unmount} = renderSync(socket, api, store);
|
|
143
|
+
|
|
144
|
+
expect(socket.emit).not.toHaveBeenCalledWith("subscribe:model", "todos");
|
|
145
|
+
|
|
146
|
+
act(() => {
|
|
147
|
+
socket.trigger("connect");
|
|
148
|
+
});
|
|
149
|
+
expect(socket.emit).toHaveBeenCalledWith("subscribe:model", "todos");
|
|
150
|
+
|
|
151
|
+
// Not connected on cleanup, so no unsubscribe emit.
|
|
152
|
+
socket.emit.mockClear();
|
|
153
|
+
unmount();
|
|
154
|
+
expect(socket.emit).not.toHaveBeenCalledWith("unsubscribe:model", "todos");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("ignores events for collections that are not tracked", () => {
|
|
158
|
+
const socket = createFakeSocket(true);
|
|
159
|
+
const {store, api, invalidateTags} = createApi({});
|
|
160
|
+
renderSync(socket, api, store);
|
|
161
|
+
|
|
162
|
+
act(() => {
|
|
163
|
+
socket.trigger("sync", syncEvent({collection: "other", method: "create"}));
|
|
164
|
+
});
|
|
165
|
+
expect(invalidateTags).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("invalidates tags for create events", () => {
|
|
169
|
+
const socket = createFakeSocket(true);
|
|
170
|
+
const {store, api, invalidateTags} = createApi({});
|
|
171
|
+
renderSync(socket, api, store);
|
|
172
|
+
|
|
173
|
+
act(() => {
|
|
174
|
+
socket.trigger("sync", syncEvent({method: "create"}));
|
|
175
|
+
});
|
|
176
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("invalidates tags for update events without data", () => {
|
|
180
|
+
const socket = createFakeSocket(true);
|
|
181
|
+
const {store, api, invalidateTags} = createApi({});
|
|
182
|
+
renderSync(socket, api, store);
|
|
183
|
+
|
|
184
|
+
act(() => {
|
|
185
|
+
socket.trigger("sync", syncEvent({data: undefined, method: "update"}));
|
|
186
|
+
});
|
|
187
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("invalidates tags for update events when there are no cached queries", () => {
|
|
191
|
+
const socket = createFakeSocket(true);
|
|
192
|
+
const {store, api, invalidateTags} = createApi(null);
|
|
193
|
+
renderSync(socket, api, store);
|
|
194
|
+
|
|
195
|
+
act(() => {
|
|
196
|
+
socket.trigger("sync", syncEvent({data: {name: "new"}, method: "update"}));
|
|
197
|
+
});
|
|
198
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("patches a matching entity inside a cached list query on update", () => {
|
|
202
|
+
const socket = createFakeSocket(true);
|
|
203
|
+
const queries = {
|
|
204
|
+
"getTodos()": {
|
|
205
|
+
data: {data: [{_id: "1", name: "old", updated: "2026-01-01T00:00:00.000Z"}], total: 1},
|
|
206
|
+
endpointName: "getTodos",
|
|
207
|
+
originalArgs: undefined,
|
|
208
|
+
status: "fulfilled",
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
const {store, api, updateQueryData, invalidateTags} = createApi(queries);
|
|
212
|
+
renderSync(socket, api, store);
|
|
213
|
+
|
|
214
|
+
act(() => {
|
|
215
|
+
socket.trigger(
|
|
216
|
+
"sync",
|
|
217
|
+
syncEvent({data: {name: "new", updated: "2026-02-01T00:00:00.000Z"}, method: "update"})
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(updateQueryData).toHaveBeenCalled();
|
|
222
|
+
expect(queries["getTodos()"].data.data[0].name).toBe("new");
|
|
223
|
+
expect(invalidateTags).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("skips stale updates in a cached list query", () => {
|
|
227
|
+
const socket = createFakeSocket(true);
|
|
228
|
+
const queries = {
|
|
229
|
+
"getTodos()": {
|
|
230
|
+
data: {data: [{_id: "1", name: "current", updated: "2026-03-01T00:00:00.000Z"}], total: 1},
|
|
231
|
+
endpointName: "getTodos",
|
|
232
|
+
originalArgs: undefined,
|
|
233
|
+
status: "fulfilled",
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
const {store, api} = createApi(queries);
|
|
237
|
+
renderSync(socket, api, store);
|
|
238
|
+
|
|
239
|
+
act(() => {
|
|
240
|
+
socket.trigger(
|
|
241
|
+
"sync",
|
|
242
|
+
syncEvent({data: {name: "stale", updated: "2026-01-01T00:00:00.000Z"}, method: "update"})
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(queries["getTodos()"].data.data[0].name).toBe("current");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("patches a matching single-entity cached query on update", () => {
|
|
250
|
+
const socket = createFakeSocket(true);
|
|
251
|
+
const queries = {
|
|
252
|
+
"getTodoById(1)": {
|
|
253
|
+
data: {_id: "1", name: "old", updated: "2026-01-01T00:00:00.000Z"},
|
|
254
|
+
endpointName: "getTodoById",
|
|
255
|
+
originalArgs: {id: "1"},
|
|
256
|
+
status: "fulfilled",
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
const {store, api, updateQueryData} = createApi(queries);
|
|
260
|
+
renderSync(socket, api, store);
|
|
261
|
+
|
|
262
|
+
act(() => {
|
|
263
|
+
socket.trigger(
|
|
264
|
+
"sync",
|
|
265
|
+
syncEvent({data: {name: "new", updated: "2026-02-01T00:00:00.000Z"}, method: "update"})
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(updateQueryData).toHaveBeenCalled();
|
|
270
|
+
expect(queries["getTodoById(1)"].data.name).toBe("new");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("skips stale updates for a single-entity cached query", () => {
|
|
274
|
+
const socket = createFakeSocket(true);
|
|
275
|
+
const queries = {
|
|
276
|
+
"getTodoById(1)": {
|
|
277
|
+
data: {_id: "1", name: "current", updated: "2026-03-01T00:00:00.000Z"},
|
|
278
|
+
endpointName: "getTodoById",
|
|
279
|
+
originalArgs: {id: "1"},
|
|
280
|
+
status: "fulfilled",
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
const {store, api, invalidateTags} = createApi(queries);
|
|
284
|
+
renderSync(socket, api, store);
|
|
285
|
+
|
|
286
|
+
act(() => {
|
|
287
|
+
socket.trigger(
|
|
288
|
+
"sync",
|
|
289
|
+
syncEvent({data: {name: "stale", updated: "2026-01-01T00:00:00.000Z"}, method: "update"})
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(queries["getTodoById(1)"].data.name).toBe("current");
|
|
294
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("invalidates tags on update when no cached query matches", () => {
|
|
298
|
+
const socket = createFakeSocket(true);
|
|
299
|
+
const queries = {
|
|
300
|
+
"getOthers()": {
|
|
301
|
+
data: {data: [{_id: "999", name: "unrelated"}]},
|
|
302
|
+
endpointName: "getOthers",
|
|
303
|
+
originalArgs: undefined,
|
|
304
|
+
status: "fulfilled",
|
|
305
|
+
},
|
|
306
|
+
"pending()": {
|
|
307
|
+
data: {data: [{_id: "1"}]},
|
|
308
|
+
endpointName: "pending",
|
|
309
|
+
originalArgs: undefined,
|
|
310
|
+
status: "pending",
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
const {store, api, invalidateTags} = createApi(queries);
|
|
314
|
+
renderSync(socket, api, store);
|
|
315
|
+
|
|
316
|
+
act(() => {
|
|
317
|
+
socket.trigger("sync", syncEvent({data: {name: "new"}, method: "update"}));
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("removes a deleted entity from cached list queries and decrements total", () => {
|
|
324
|
+
const socket = createFakeSocket(true);
|
|
325
|
+
const queries = {
|
|
326
|
+
"getTodos()": {
|
|
327
|
+
data: {
|
|
328
|
+
data: [
|
|
329
|
+
{_id: "1", name: "a"},
|
|
330
|
+
{_id: "2", name: "b"},
|
|
331
|
+
],
|
|
332
|
+
total: 2,
|
|
333
|
+
},
|
|
334
|
+
endpointName: "getTodos",
|
|
335
|
+
originalArgs: undefined,
|
|
336
|
+
status: "fulfilled",
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
const {store, api, updateQueryData} = createApi(queries);
|
|
340
|
+
renderSync(socket, api, store);
|
|
341
|
+
|
|
342
|
+
act(() => {
|
|
343
|
+
socket.trigger("sync", syncEvent({method: "delete"}));
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
expect(updateQueryData).toHaveBeenCalled();
|
|
347
|
+
expect(queries["getTodos()"].data.data).toHaveLength(1);
|
|
348
|
+
expect(queries["getTodos()"].data.total).toBe(1);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("invalidates tags on delete when there are no cached queries", () => {
|
|
352
|
+
const socket = createFakeSocket(true);
|
|
353
|
+
const {store, api, invalidateTags} = createApi(null);
|
|
354
|
+
renderSync(socket, api, store);
|
|
355
|
+
|
|
356
|
+
act(() => {
|
|
357
|
+
socket.trigger("sync", syncEvent({method: "delete"}));
|
|
358
|
+
});
|
|
359
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("invalidates tags on delete when no cached query matches", () => {
|
|
363
|
+
const socket = createFakeSocket(true);
|
|
364
|
+
const queries = {
|
|
365
|
+
"getOthers()": {
|
|
366
|
+
data: {data: [{_id: "999"}]},
|
|
367
|
+
endpointName: "getOthers",
|
|
368
|
+
originalArgs: undefined,
|
|
369
|
+
status: "fulfilled",
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
const {store, api, invalidateTags} = createApi(queries);
|
|
373
|
+
renderSync(socket, api, store);
|
|
374
|
+
|
|
375
|
+
act(() => {
|
|
376
|
+
socket.trigger("sync", syncEvent({method: "delete"}));
|
|
377
|
+
});
|
|
378
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("does not log sync details when debug is disabled", () => {
|
|
382
|
+
const socket = createFakeSocket(true);
|
|
383
|
+
const {store, api, invalidateTags} = createApi({});
|
|
384
|
+
renderSync(socket, api, store, {debug: false});
|
|
385
|
+
|
|
386
|
+
act(() => {
|
|
387
|
+
socket.trigger("sync", syncEvent({method: "create"}));
|
|
388
|
+
});
|
|
389
|
+
expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
|
|
390
|
+
});
|
|
391
|
+
});
|
package/src/useFeatureFlags.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {Api} from "@reduxjs/toolkit/query/react";
|
|
2
|
+
import {DateTime} from "luxon";
|
|
2
3
|
import {useCallback, useEffect, useMemo, useRef} from "react";
|
|
3
4
|
import {useTerrenoFeatureFlags} from "./useTerrenoFeatureFlags";
|
|
4
5
|
|
|
@@ -104,7 +105,7 @@ export const useFeatureFlags = (
|
|
|
104
105
|
return;
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
fetchStartedAtRef.current =
|
|
108
|
+
fetchStartedAtRef.current = DateTime.now().toMillis();
|
|
108
109
|
console.debug("[feature-flags] flagConfiguration request started", {
|
|
109
110
|
basePath,
|
|
110
111
|
});
|
|
@@ -115,7 +116,7 @@ export const useFeatureFlags = (
|
|
|
115
116
|
return;
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
const durationMs =
|
|
119
|
+
const durationMs = DateTime.now().toMillis() - fetchStartedAtRef.current;
|
|
119
120
|
fetchStartedAtRef.current = null;
|
|
120
121
|
console.debug("[feature-flags] flagConfiguration request failed", {
|
|
121
122
|
basePath,
|
|
@@ -142,7 +143,7 @@ export const useFeatureFlags = (
|
|
|
142
143
|
return;
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
const durationMs =
|
|
146
|
+
const durationMs = DateTime.now().toMillis() - fetchStartedAtRef.current;
|
|
146
147
|
fetchStartedAtRef.current = null;
|
|
147
148
|
console.debug("[feature-flags] flagConfiguration request completed", {
|
|
148
149
|
basePath,
|
|
@@ -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
|
+
});
|