@syncular/client-react 0.0.1-60

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,468 @@
1
+ /**
2
+ * Tests for useMutation hook
3
+ *
4
+ * Covers:
5
+ * - Fluent API (upsert, delete)
6
+ * - Legacy mutate interface
7
+ * - mutateMany for batch operations
8
+ * - isPending state
9
+ * - error handling and onError callback
10
+ * - onSuccess callback
11
+ * - syncImmediately option
12
+ * - reset function
13
+ */
14
+
15
+ import { beforeEach, describe, expect, it } from 'bun:test';
16
+ import type { SyncClientDb } from '@syncular/client';
17
+ import { act, renderHook } from '@testing-library/react';
18
+ import type { Kysely } from 'kysely';
19
+ import type { ReactNode } from 'react';
20
+ import { createSyncularReact } from '../../index';
21
+ import {
22
+ createMockDb,
23
+ createMockShapeRegistry,
24
+ createMockTransport,
25
+ } from '../test-utils';
26
+
27
+ interface TestDbTasks {
28
+ id: string;
29
+ title: string;
30
+ completed: number;
31
+ user_id: string;
32
+ server_version?: number;
33
+ }
34
+
35
+ interface TestDb extends SyncClientDb {
36
+ tasks: TestDbTasks;
37
+ }
38
+
39
+ const { SyncProvider, useMutation } = createSyncularReact<TestDb>();
40
+
41
+ describe('useMutation', () => {
42
+ let db: Kysely<TestDb>;
43
+
44
+ beforeEach(async () => {
45
+ db = await createMockDb<TestDb>();
46
+
47
+ await db.schema
48
+ .createTable('tasks')
49
+ .ifNotExists()
50
+ .addColumn('id', 'text', (col) => col.primaryKey())
51
+ .addColumn('title', 'text', (col) => col.notNull())
52
+ .addColumn('completed', 'integer', (col) => col.notNull().defaultTo(0))
53
+ .addColumn('user_id', 'text', (col) => col.notNull())
54
+ .addColumn('server_version', 'integer', (col) =>
55
+ col.notNull().defaultTo(0)
56
+ )
57
+ .execute();
58
+ });
59
+
60
+ function createWrapper() {
61
+ const transport = createMockTransport();
62
+ const shapes = createMockShapeRegistry<TestDb>();
63
+
64
+ const Wrapper = ({ children }: { children: ReactNode }) => (
65
+ <SyncProvider
66
+ db={db}
67
+ transport={transport}
68
+ shapes={shapes}
69
+ actorId="test-actor"
70
+ clientId="test-client"
71
+ subscriptions={[]}
72
+ pollIntervalMs={999999}
73
+ autoStart={false}
74
+ >
75
+ {children}
76
+ </SyncProvider>
77
+ );
78
+
79
+ return Wrapper;
80
+ }
81
+
82
+ describe('fluent API', () => {
83
+ it('mutate.upsert() enqueues an outbox commit', async () => {
84
+ const { result } = renderHook(
85
+ () => useMutation({ table: 'tasks', syncImmediately: false }),
86
+ { wrapper: createWrapper() }
87
+ );
88
+
89
+ await act(async () => {
90
+ const res = await result.current.mutate.upsert('task-1', {
91
+ title: 'Test Task',
92
+ completed: 0,
93
+ user_id: 'test-actor',
94
+ });
95
+ expect(res.commitId).toBeTruthy();
96
+ expect(res.clientCommitId).toBeTruthy();
97
+ });
98
+
99
+ const outbox = await db
100
+ .selectFrom('sync_outbox_commits')
101
+ .select(['id', 'operations_json'])
102
+ .execute();
103
+ expect(outbox.length).toBe(1);
104
+
105
+ const ops = JSON.parse(outbox[0]!.operations_json);
106
+ expect(ops.length).toBe(1);
107
+ expect(ops[0].op).toBe('upsert');
108
+ expect(ops[0].row_id).toBe('task-1');
109
+ });
110
+
111
+ it('mutate.delete() enqueues a delete operation', async () => {
112
+ const { result } = renderHook(
113
+ () => useMutation({ table: 'tasks', syncImmediately: false }),
114
+ { wrapper: createWrapper() }
115
+ );
116
+
117
+ await act(async () => {
118
+ await result.current.mutate.delete('task-1');
119
+ });
120
+
121
+ const outbox = await db
122
+ .selectFrom('sync_outbox_commits')
123
+ .select(['operations_json'])
124
+ .executeTakeFirstOrThrow();
125
+
126
+ const ops = JSON.parse(outbox.operations_json);
127
+ expect(ops.length).toBe(1);
128
+ expect(ops[0].op).toBe('delete');
129
+ expect(ops[0].row_id).toBe('task-1');
130
+ });
131
+
132
+ it('mutate.upsert() supports baseVersion option', async () => {
133
+ const { result } = renderHook(
134
+ () => useMutation({ table: 'tasks', syncImmediately: false }),
135
+ { wrapper: createWrapper() }
136
+ );
137
+
138
+ await act(async () => {
139
+ await result.current.mutate.upsert(
140
+ 'task-1',
141
+ { title: 'Updated', completed: 1, user_id: 'test-actor' },
142
+ { baseVersion: 5 }
143
+ );
144
+ });
145
+
146
+ const outbox = await db
147
+ .selectFrom('sync_outbox_commits')
148
+ .select(['operations_json'])
149
+ .executeTakeFirstOrThrow();
150
+
151
+ const ops = JSON.parse(outbox.operations_json);
152
+ expect(ops[0].base_version).toBe(5);
153
+ });
154
+
155
+ it('mutate.delete() supports baseVersion option', async () => {
156
+ const { result } = renderHook(
157
+ () => useMutation({ table: 'tasks', syncImmediately: false }),
158
+ { wrapper: createWrapper() }
159
+ );
160
+
161
+ await act(async () => {
162
+ await result.current.mutate.delete('task-1', { baseVersion: 3 });
163
+ });
164
+
165
+ const outbox = await db
166
+ .selectFrom('sync_outbox_commits')
167
+ .select(['operations_json'])
168
+ .executeTakeFirstOrThrow();
169
+
170
+ const ops = JSON.parse(outbox.operations_json);
171
+ expect(ops[0].base_version).toBe(3);
172
+ });
173
+ });
174
+
175
+ describe('legacy mutate interface', () => {
176
+ it('mutate() with MutationInput works', async () => {
177
+ const { result } = renderHook(
178
+ () => useMutation({ table: 'tasks', syncImmediately: false }),
179
+ { wrapper: createWrapper() }
180
+ );
181
+
182
+ await act(async () => {
183
+ await result.current.mutate({
184
+ rowId: 'task-legacy',
185
+ op: 'upsert',
186
+ payload: { title: 'Legacy', completed: 0, user_id: 'test-actor' },
187
+ });
188
+ });
189
+
190
+ const outbox = await db
191
+ .selectFrom('sync_outbox_commits')
192
+ .select(['operations_json'])
193
+ .executeTakeFirstOrThrow();
194
+
195
+ const ops = JSON.parse(outbox.operations_json);
196
+ expect(ops[0].row_id).toBe('task-legacy');
197
+ });
198
+
199
+ it('throws when MutationInput.table does not match hook table', async () => {
200
+ const { result } = renderHook(
201
+ () => useMutation({ table: 'tasks', syncImmediately: false }),
202
+ { wrapper: createWrapper() }
203
+ );
204
+
205
+ let thrownError: Error | null = null;
206
+ await act(async () => {
207
+ try {
208
+ await result.current.mutate({
209
+ // @ts-expect-error - runtime guard for mismatched table
210
+ table: 'other_table',
211
+ rowId: 'task-1',
212
+ op: 'upsert',
213
+ payload: { title: 'Test' },
214
+ });
215
+ } catch (err) {
216
+ thrownError = err as Error;
217
+ }
218
+ });
219
+
220
+ expect(thrownError).not.toBeNull();
221
+ expect(thrownError!.message).toContain(
222
+ 'MutationInput.table must match hook table'
223
+ );
224
+ });
225
+ });
226
+
227
+ describe('mutateMany', () => {
228
+ it('batches multiple operations into a single commit', async () => {
229
+ const { result } = renderHook(
230
+ () => useMutation({ table: 'tasks', syncImmediately: false }),
231
+ { wrapper: createWrapper() }
232
+ );
233
+
234
+ await act(async () => {
235
+ await result.current.mutateMany([
236
+ {
237
+ rowId: 'task-1',
238
+ op: 'upsert',
239
+ payload: { title: 'Task 1', completed: 0, user_id: 'test-actor' },
240
+ },
241
+ {
242
+ rowId: 'task-2',
243
+ op: 'upsert',
244
+ payload: { title: 'Task 2', completed: 0, user_id: 'test-actor' },
245
+ },
246
+ { rowId: 'task-3', op: 'delete' },
247
+ ]);
248
+ });
249
+
250
+ const outbox = await db
251
+ .selectFrom('sync_outbox_commits')
252
+ .select(['operations_json'])
253
+ .execute();
254
+
255
+ expect(outbox.length).toBe(1);
256
+
257
+ const ops = JSON.parse(outbox[0]!.operations_json);
258
+ expect(ops.length).toBe(3);
259
+ expect(ops[0].op).toBe('upsert');
260
+ expect(ops[1].op).toBe('upsert');
261
+ expect(ops[2].op).toBe('delete');
262
+ });
263
+ });
264
+
265
+ describe('isPending state', () => {
266
+ it('isPending is false before and after mutation', async () => {
267
+ const { result } = renderHook(
268
+ () => useMutation({ table: 'tasks', syncImmediately: false }),
269
+ { wrapper: createWrapper() }
270
+ );
271
+
272
+ expect(result.current.isPending).toBe(false);
273
+
274
+ await act(async () => {
275
+ await result.current.mutate.upsert('task-1', {
276
+ title: 'Test',
277
+ completed: 0,
278
+ user_id: 'test-actor',
279
+ });
280
+ });
281
+
282
+ expect(result.current.isPending).toBe(false);
283
+ });
284
+
285
+ it('isPending resets after error', async () => {
286
+ const { result } = renderHook(
287
+ () => useMutation({ table: 'tasks', syncImmediately: false }),
288
+ { wrapper: createWrapper() }
289
+ );
290
+
291
+ await act(async () => {
292
+ try {
293
+ await result.current.mutate({
294
+ // @ts-expect-error - runtime guard for mismatched table
295
+ table: 'wrong',
296
+ rowId: 'x',
297
+ op: 'upsert',
298
+ payload: {},
299
+ });
300
+ } catch {
301
+ // Expected
302
+ }
303
+ });
304
+
305
+ expect(result.current.isPending).toBe(false);
306
+ });
307
+ });
308
+
309
+ describe('error handling', () => {
310
+ it('sets error state on failure', async () => {
311
+ const { result } = renderHook(
312
+ () => useMutation({ table: 'tasks', syncImmediately: false }),
313
+ { wrapper: createWrapper() }
314
+ );
315
+
316
+ await act(async () => {
317
+ try {
318
+ await result.current.mutate({
319
+ // @ts-expect-error - runtime guard for mismatched table
320
+ table: 'wrong',
321
+ rowId: 'x',
322
+ op: 'upsert',
323
+ payload: {},
324
+ });
325
+ } catch {
326
+ // Expected
327
+ }
328
+ });
329
+
330
+ expect(result.current.error).not.toBeNull();
331
+ expect(result.current.error?.message).toContain(
332
+ 'MutationInput.table must match'
333
+ );
334
+ });
335
+
336
+ it('calls onError callback on failure', async () => {
337
+ let capturedError: Error | null = null;
338
+
339
+ const { result } = renderHook(
340
+ () =>
341
+ useMutation({
342
+ table: 'tasks',
343
+ syncImmediately: false,
344
+ onError: (err) => {
345
+ capturedError = err;
346
+ },
347
+ }),
348
+ { wrapper: createWrapper() }
349
+ );
350
+
351
+ await act(async () => {
352
+ try {
353
+ await result.current.mutate({
354
+ // @ts-expect-error - runtime guard for mismatched table
355
+ table: 'wrong',
356
+ rowId: 'x',
357
+ op: 'upsert',
358
+ payload: {},
359
+ });
360
+ } catch {
361
+ // Expected
362
+ }
363
+ });
364
+
365
+ expect(capturedError).not.toBeNull();
366
+ expect(capturedError!.message).toContain(
367
+ 'MutationInput.table must match'
368
+ );
369
+ });
370
+
371
+ it('reset() clears error state', async () => {
372
+ const { result } = renderHook(
373
+ () => useMutation({ table: 'tasks', syncImmediately: false }),
374
+ { wrapper: createWrapper() }
375
+ );
376
+
377
+ await act(async () => {
378
+ try {
379
+ await result.current.mutate({
380
+ // @ts-expect-error - runtime guard for mismatched table
381
+ table: 'wrong',
382
+ rowId: 'x',
383
+ op: 'upsert',
384
+ payload: {},
385
+ });
386
+ } catch {
387
+ // Expected
388
+ }
389
+ });
390
+
391
+ expect(result.current.error).not.toBeNull();
392
+
393
+ act(() => {
394
+ result.current.reset();
395
+ });
396
+
397
+ expect(result.current.error).toBeNull();
398
+ });
399
+ });
400
+
401
+ describe('onSuccess callback', () => {
402
+ it('calls onSuccess after successful mutation', async () => {
403
+ let successResult: { commitId: string; clientCommitId: string } | null =
404
+ null;
405
+
406
+ const { result } = renderHook(
407
+ () =>
408
+ useMutation({
409
+ table: 'tasks',
410
+ syncImmediately: false,
411
+ onSuccess: (res) => {
412
+ successResult = res;
413
+ },
414
+ }),
415
+ { wrapper: createWrapper() }
416
+ );
417
+
418
+ await act(async () => {
419
+ await result.current.mutate.upsert('task-1', {
420
+ title: 'Test',
421
+ completed: 0,
422
+ user_id: 'test-actor',
423
+ });
424
+ });
425
+
426
+ expect(successResult).not.toBeNull();
427
+ expect(successResult!.commitId).toBeTruthy();
428
+ expect(successResult!.clientCommitId).toBeTruthy();
429
+ });
430
+ });
431
+
432
+ describe('syncImmediately option', () => {
433
+ it('syncImmediately=true triggers sync after mutation (default)', async () => {
434
+ const { result } = renderHook(() => useMutation({ table: 'tasks' }), {
435
+ wrapper: createWrapper(),
436
+ });
437
+
438
+ await act(async () => {
439
+ await result.current.mutate.upsert('task-1', {
440
+ title: 'Test',
441
+ completed: 0,
442
+ user_id: 'test-actor',
443
+ });
444
+ });
445
+
446
+ // Sync is called in background - we just verify no error is thrown
447
+ // The actual sync behavior is tested in integration tests
448
+ });
449
+
450
+ it('syncImmediately=false does not trigger sync', async () => {
451
+ const { result } = renderHook(
452
+ () => useMutation({ table: 'tasks', syncImmediately: false }),
453
+ { wrapper: createWrapper() }
454
+ );
455
+
456
+ await act(async () => {
457
+ await result.current.mutate.upsert('task-1', {
458
+ title: 'Test',
459
+ completed: 0,
460
+ user_id: 'test-actor',
461
+ });
462
+ });
463
+
464
+ // With syncImmediately=false, no sync is triggered
465
+ // This is verified by the mock transport not receiving push requests
466
+ });
467
+ });
468
+ });