@syncular/client-react 0.0.1

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,384 @@
1
+ /**
2
+ * Tests for React hooks
3
+ *
4
+ * These tests use `autoStart={false}` to prevent the SyncProvider from
5
+ * auto-starting the engine, allowing tests to control the lifecycle.
6
+ */
7
+
8
+ import { beforeEach, describe, expect, it } from 'bun:test';
9
+ import type { SyncClientDb } from '@syncular/client';
10
+ import { act, renderHook, waitFor } from '@testing-library/react';
11
+ import type { Kysely } from 'kysely';
12
+ import type { ReactNode } from 'react';
13
+ import { createSyncularReact } from '../index';
14
+ import {
15
+ createMockDb,
16
+ createMockShapeRegistry,
17
+ createMockTransport,
18
+ } from './test-utils';
19
+
20
+ const {
21
+ SyncProvider,
22
+ useConflicts,
23
+ useOutbox,
24
+ useResolveConflict,
25
+ useSyncConnection,
26
+ useSyncEngine,
27
+ useSyncStatus,
28
+ } = createSyncularReact<SyncClientDb>();
29
+
30
+ describe('React Hooks', () => {
31
+ let db: Kysely<SyncClientDb>;
32
+
33
+ beforeEach(async () => {
34
+ db = await createMockDb();
35
+ });
36
+
37
+ function createWrapper(options?: { autoStart?: boolean }) {
38
+ const transport = createMockTransport();
39
+ const shapes = createMockShapeRegistry();
40
+
41
+ const Wrapper = ({ children }: { children: ReactNode }) => (
42
+ <SyncProvider
43
+ db={db}
44
+ transport={transport}
45
+ shapes={shapes}
46
+ actorId="test-actor"
47
+ clientId="test-client"
48
+ subscriptions={[]}
49
+ pollIntervalMs={999999} // Long poll interval to prevent continuous polling
50
+ autoStart={options?.autoStart ?? false} // Disable auto-start for tests
51
+ >
52
+ {children}
53
+ </SyncProvider>
54
+ );
55
+
56
+ return Wrapper;
57
+ }
58
+
59
+ describe('useSyncEngine', () => {
60
+ it('should return engine state', async () => {
61
+ const { result } = renderHook(() => useSyncEngine(), {
62
+ wrapper: createWrapper(),
63
+ });
64
+
65
+ expect(result.current.state).toBeDefined();
66
+ expect(result.current.state.enabled).toBe(true);
67
+ });
68
+
69
+ it('should provide sync function', async () => {
70
+ const { result } = renderHook(() => useSyncEngine(), {
71
+ wrapper: createWrapper(),
72
+ });
73
+
74
+ expect(typeof result.current.sync).toBe('function');
75
+ });
76
+
77
+ it('should provide control functions', () => {
78
+ const { result } = renderHook(() => useSyncEngine(), {
79
+ wrapper: createWrapper(),
80
+ });
81
+
82
+ expect(typeof result.current.reconnect).toBe('function');
83
+ expect(typeof result.current.disconnect).toBe('function');
84
+ expect(typeof result.current.start).toBe('function');
85
+ });
86
+ });
87
+
88
+ describe('useSyncStatus', () => {
89
+ it('should return status object', () => {
90
+ const { result } = renderHook(() => useSyncStatus(), {
91
+ wrapper: createWrapper(),
92
+ });
93
+
94
+ expect(result.current).toMatchObject({
95
+ enabled: true,
96
+ isOnline: expect.any(Boolean),
97
+ isSyncing: expect.any(Boolean),
98
+ pendingCount: expect.any(Number),
99
+ error: null,
100
+ isRetrying: false,
101
+ retryCount: 0,
102
+ });
103
+ });
104
+
105
+ it('should show lastSyncAt after manual sync', async () => {
106
+ const { result } = renderHook(
107
+ () => ({
108
+ status: useSyncStatus(),
109
+ engine: useSyncEngine(),
110
+ }),
111
+ { wrapper: createWrapper() }
112
+ );
113
+
114
+ // Start the engine manually
115
+ await act(async () => {
116
+ await result.current.engine.start();
117
+ });
118
+
119
+ // Trigger a sync
120
+ await act(async () => {
121
+ await result.current.engine.sync();
122
+ });
123
+
124
+ expect(result.current.status.lastSyncAt).not.toBe(null);
125
+ });
126
+ });
127
+
128
+ describe('useSyncConnection', () => {
129
+ it('should return connection state', () => {
130
+ const { result } = renderHook(() => useSyncConnection(), {
131
+ wrapper: createWrapper(),
132
+ });
133
+
134
+ expect(result.current.state).toBeDefined();
135
+ expect(result.current.mode).toBe('polling');
136
+ expect(typeof result.current.isConnected).toBe('boolean');
137
+ expect(typeof result.current.isReconnecting).toBe('boolean');
138
+ });
139
+
140
+ it('should provide reconnect and disconnect functions', () => {
141
+ const { result } = renderHook(() => useSyncConnection(), {
142
+ wrapper: createWrapper(),
143
+ });
144
+
145
+ expect(typeof result.current.reconnect).toBe('function');
146
+ expect(typeof result.current.disconnect).toBe('function');
147
+ });
148
+
149
+ // Note: Connection lifecycle tests are covered in integration tests
150
+ // The SyncEngine.test.ts tests the engine directly
151
+ // These hook tests verify the React binding works
152
+ });
153
+
154
+ describe('useOutbox', () => {
155
+ it('should return outbox stats', async () => {
156
+ const { result } = renderHook(() => useOutbox(), {
157
+ wrapper: createWrapper(),
158
+ });
159
+
160
+ await waitFor(() => {
161
+ expect(result.current.isLoading).toBe(false);
162
+ });
163
+
164
+ expect(result.current.stats).toMatchObject({
165
+ pending: 0,
166
+ sending: 0,
167
+ failed: 0,
168
+ acked: 0,
169
+ total: 0,
170
+ });
171
+ expect(result.current.hasUnsent).toBe(false);
172
+ });
173
+
174
+ it('should return empty pending and failed arrays initially', async () => {
175
+ const { result } = renderHook(() => useOutbox(), {
176
+ wrapper: createWrapper(),
177
+ });
178
+
179
+ await waitFor(() => {
180
+ expect(result.current.isLoading).toBe(false);
181
+ });
182
+
183
+ expect(result.current.pending).toEqual([]);
184
+ expect(result.current.failed).toEqual([]);
185
+ });
186
+ });
187
+
188
+ describe('useConflicts', () => {
189
+ it('should return empty conflicts initially', async () => {
190
+ const { result } = renderHook(() => useConflicts(), {
191
+ wrapper: createWrapper(),
192
+ });
193
+
194
+ await waitFor(() => {
195
+ expect(result.current.isLoading).toBe(false);
196
+ });
197
+
198
+ expect(result.current.conflicts).toEqual([]);
199
+ expect(result.current.count).toBe(0);
200
+ expect(result.current.hasConflicts).toBe(false);
201
+ });
202
+ });
203
+
204
+ describe('useResolveConflict', () => {
205
+ it('should return resolve function and state', () => {
206
+ const { result } = renderHook(() => useResolveConflict(), {
207
+ wrapper: createWrapper(),
208
+ });
209
+
210
+ expect(typeof result.current.resolve).toBe('function');
211
+ expect(result.current.isPending).toBe(false);
212
+ expect(result.current.error).toBe(null);
213
+ expect(typeof result.current.reset).toBe('function');
214
+ });
215
+
216
+ it('should set isPending during resolution', async () => {
217
+ const { result } = renderHook(() => useResolveConflict(), {
218
+ wrapper: createWrapper(),
219
+ });
220
+
221
+ // Try to resolve a non-existent conflict (will throw but we test the pending state)
222
+ const resolvePromise = act(async () => {
223
+ try {
224
+ await result.current.resolve('non-existent', 'accept');
225
+ } catch {
226
+ // Expected to fail - conflict doesn't exist
227
+ }
228
+ });
229
+
230
+ await resolvePromise;
231
+
232
+ // After resolution attempt, isPending should be false
233
+ expect(result.current.isPending).toBe(false);
234
+ });
235
+
236
+ it('should handle accept resolution type', async () => {
237
+ // Create a conflict in the database first
238
+ await db
239
+ .insertInto('sync_conflicts')
240
+ .values({
241
+ id: 'test-conflict-1',
242
+ outbox_commit_id: 'commit-1',
243
+ client_commit_id: 'client-commit-1',
244
+ op_index: 0,
245
+ result_status: 'conflict',
246
+ message: 'Version conflict',
247
+ code: 'VERSION_MISMATCH',
248
+ server_version: 2,
249
+ server_row_json: JSON.stringify({
250
+ id: 'row-1',
251
+ title: 'Server Title',
252
+ }),
253
+ created_at: Date.now(),
254
+ resolved_at: null,
255
+ resolution: null,
256
+ })
257
+ .execute();
258
+
259
+ const { result } = renderHook(
260
+ () => ({
261
+ resolve: useResolveConflict({ syncAfterResolve: false }),
262
+ conflicts: useConflicts(),
263
+ }),
264
+ { wrapper: createWrapper() }
265
+ );
266
+
267
+ // Wait for conflicts to load
268
+ await waitFor(() => {
269
+ expect(result.current.conflicts.isLoading).toBe(false);
270
+ });
271
+
272
+ // Resolve the conflict
273
+ await act(async () => {
274
+ await result.current.resolve.resolve('test-conflict-1', 'accept');
275
+ });
276
+
277
+ // Verify conflict was resolved
278
+ const resolved = await db
279
+ .selectFrom('sync_conflicts')
280
+ .where('id', '=', 'test-conflict-1')
281
+ .selectAll()
282
+ .executeTakeFirst();
283
+
284
+ expect(resolved?.resolution).toBe('accept');
285
+ expect(resolved?.resolved_at).not.toBe(null);
286
+ });
287
+
288
+ it('should handle reject resolution type', async () => {
289
+ // Create a conflict
290
+ await db
291
+ .insertInto('sync_conflicts')
292
+ .values({
293
+ id: 'test-conflict-2',
294
+ outbox_commit_id: 'commit-2',
295
+ client_commit_id: 'client-commit-2',
296
+ op_index: 0,
297
+ result_status: 'conflict',
298
+ message: 'Version conflict',
299
+ code: 'VERSION_MISMATCH',
300
+ server_version: 3,
301
+ server_row_json: null,
302
+ created_at: Date.now(),
303
+ resolved_at: null,
304
+ resolution: null,
305
+ })
306
+ .execute();
307
+
308
+ const { result } = renderHook(
309
+ () => useResolveConflict({ syncAfterResolve: false }),
310
+ { wrapper: createWrapper() }
311
+ );
312
+
313
+ await act(async () => {
314
+ await result.current.resolve('test-conflict-2', 'reject');
315
+ });
316
+
317
+ const resolved = await db
318
+ .selectFrom('sync_conflicts')
319
+ .where('id', '=', 'test-conflict-2')
320
+ .selectAll()
321
+ .executeTakeFirst();
322
+
323
+ expect(resolved?.resolution).toBe('reject');
324
+ });
325
+
326
+ it('should call onSuccess callback on successful resolution', async () => {
327
+ await db
328
+ .insertInto('sync_conflicts')
329
+ .values({
330
+ id: 'test-conflict-3',
331
+ outbox_commit_id: 'commit-3',
332
+ client_commit_id: 'client-commit-3',
333
+ op_index: 0,
334
+ result_status: 'conflict',
335
+ message: 'Version conflict',
336
+ code: null,
337
+ server_version: 1,
338
+ server_row_json: null,
339
+ created_at: Date.now(),
340
+ resolved_at: null,
341
+ resolution: null,
342
+ })
343
+ .execute();
344
+
345
+ let successId: string | null = null;
346
+ const onSuccess = (id: string) => {
347
+ successId = id;
348
+ };
349
+
350
+ const { result } = renderHook(
351
+ () => useResolveConflict({ onSuccess, syncAfterResolve: false }),
352
+ { wrapper: createWrapper() }
353
+ );
354
+
355
+ await act(async () => {
356
+ await result.current.resolve('test-conflict-3', 'accept');
357
+ });
358
+
359
+ expect(successId!).toBe('test-conflict-3');
360
+ });
361
+
362
+ it('should reset error state', async () => {
363
+ const { result } = renderHook(() => useResolveConflict(), {
364
+ wrapper: createWrapper(),
365
+ });
366
+
367
+ // Trigger an error by resolving non-existent conflict
368
+ await act(async () => {
369
+ try {
370
+ await result.current.resolve('non-existent', 'accept');
371
+ } catch {
372
+ // Expected
373
+ }
374
+ });
375
+
376
+ // Reset should clear the error
377
+ act(() => {
378
+ result.current.reset();
379
+ });
380
+
381
+ expect(result.current.error).toBe(null);
382
+ });
383
+ });
384
+ });