@terreno/rtk 0.25.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.
@@ -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
+ });