meadow-integration 1.0.24 → 1.0.26

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,1265 @@
1
+ /*
2
+ Unit tests for the three new clone sync strategies:
3
+
4
+ 1. OngoingEventualConsistency — time-budgeted backwards bisection
5
+ 2. TrueUp — linear keyset-paginated walk
6
+ 3. ComparisonOnly — bisection-based diff report (no sync)
7
+
8
+ Uses a 50,000-record dataset with mixed fragmentation:
9
+ - 200 scattered updates (every 250th ID)
10
+ - 500-record contiguous gap (local missing IDs 20001-20500)
11
+ - 500 new records at the tail (IDs 50001-50500)
12
+ - 100 deleted records (IDs 50501-50600)
13
+
14
+ Infrastructure mirrors Meadow-Integration-BisectionSync_test.js:
15
+ filter-aware mock HTTP server + in-memory SQLite.
16
+ */
17
+
18
+ const Chai = require('chai');
19
+ const Expect = Chai.expect;
20
+
21
+ const libHTTP = require('http');
22
+ const libFable = require('fable');
23
+ const libMeadow = require('meadow');
24
+ const libMeadowConnectionSQLite = require('meadow-connection-sqlite');
25
+
26
+ const libMeadowCloneRestClient = require('../source/services/clone/Meadow-Service-RestClient.js');
27
+ const libMeadowSync = require('../source/services/clone/Meadow-Service-Sync.js');
28
+ const libMeadowSyncEntityInitial = require('../source/services/clone/Meadow-Service-Sync-Entity-Initial.js');
29
+ const libMeadowSyncEntityOngoing = require('../source/services/clone/Meadow-Service-Sync-Entity-Ongoing.js');
30
+ const libMeadowSyncEntityOngoingEventualConsistency = require('../source/services/clone/Meadow-Service-Sync-Entity-OngoingEventualConsistency.js');
31
+ const libMeadowSyncEntityTrueUp = require('../source/services/clone/Meadow-Service-Sync-Entity-TrueUp.js');
32
+ const libMeadowSyncEntityComparisonOnly = require('../source/services/clone/Meadow-Service-Sync-Entity-ComparisonOnly.js');
33
+
34
+ // ── Test Constants ──────────────────────────────────────────────────────────────
35
+
36
+ const MOCK_PORT = 18200;
37
+ const MOCK_BASE_URL = `http://localhost:${MOCK_PORT}/1.0/`;
38
+
39
+ const BASE_UPDATE_DATE = '2025-06-15T12:00:00.000Z';
40
+ const NEWER_UPDATE_DATE = '2025-07-01T12:00:00.000Z';
41
+ const NEWEST_UPDATE_DATE = '2025-08-01T12:00:00.000Z';
42
+
43
+ const RECORD_COUNT = 50000;
44
+ const BISECT_MIN_RANGE = 1000;
45
+
46
+ // Fragmentation parameters
47
+ const GAP_START = 20001;
48
+ const GAP_END = 20500;
49
+ const GAP_SIZE = GAP_END - GAP_START + 1;
50
+ const NEW_RECORDS_START = RECORD_COUNT + 1;
51
+ const NEW_RECORDS_END = RECORD_COUNT + 500;
52
+ const NEW_RECORDS_COUNT = NEW_RECORDS_END - NEW_RECORDS_START + 1;
53
+ const DELETED_START = NEW_RECORDS_END + 1;
54
+ const DELETED_END = DELETED_START + 99;
55
+ const DELETED_COUNT = DELETED_END - DELETED_START + 1;
56
+ const SCATTERED_UPDATE_INTERVAL = 5000;
57
+ const SCATTERED_UPDATE_COUNT = Math.floor(RECORD_COUNT / SCATTERED_UPDATE_INTERVAL);
58
+
59
+ // ── Book Entity Schema (Extended Format) ────────────────────────────────────────
60
+
61
+ const _BookExtendedSchema =
62
+ {
63
+ Tables:
64
+ {
65
+ Book:
66
+ {
67
+ TableName: 'Book',
68
+ Columns:
69
+ [
70
+ { Column: 'IDBook', DataType: 'int' },
71
+ { Column: 'GUIDBook', DataType: 'GUID' },
72
+ { Column: 'CreateDate', DataType: 'DateTime' },
73
+ { Column: 'CreatingIDUser', DataType: 'int' },
74
+ { Column: 'UpdateDate', DataType: 'DateTime' },
75
+ { Column: 'UpdatingIDUser', DataType: 'int' },
76
+ { Column: 'Deleted', DataType: 'int' },
77
+ { Column: 'DeleteDate', DataType: 'DateTime' },
78
+ { Column: 'DeletingIDUser', DataType: 'int' },
79
+ { Column: 'Title', DataType: 'String' },
80
+ { Column: 'Type', DataType: 'String' },
81
+ { Column: 'Genre', DataType: 'String' },
82
+ { Column: 'PublicationYear', DataType: 'int' }
83
+ ],
84
+ MeadowSchema:
85
+ {
86
+ Scope: 'Book',
87
+ DefaultIdentifier: 'IDBook',
88
+ Domain: 'Default',
89
+ Schema:
90
+ [
91
+ { Column: 'IDBook', Type: 'AutoIdentity', Size: 'Default' },
92
+ { Column: 'GUIDBook', Type: 'AutoGUID', Size: '128' },
93
+ { Column: 'CreateDate', Type: 'CreateDate', Size: 'Default' },
94
+ { Column: 'CreatingIDUser', Type: 'CreateIDUser', Size: 'int' },
95
+ { Column: 'UpdateDate', Type: 'UpdateDate', Size: 'Default' },
96
+ { Column: 'UpdatingIDUser', Type: 'UpdateIDUser', Size: 'int' },
97
+ { Column: 'Deleted', Type: 'Deleted', Size: 'Default' },
98
+ { Column: 'DeleteDate', Type: 'DeleteDate', Size: 'Default' },
99
+ { Column: 'DeletingIDUser', Type: 'DeleteIDUser', Size: 'int' },
100
+ { Column: 'Title', Type: 'String', Size: '200' },
101
+ { Column: 'Type', Type: 'String', Size: '32' },
102
+ { Column: 'Genre', Type: 'String', Size: '128' },
103
+ { Column: 'PublicationYear', Type: 'Integer', Size: 'int' }
104
+ ],
105
+ DefaultObject:
106
+ {
107
+ IDBook: 0, GUIDBook: '', CreateDate: null, CreatingIDUser: 0,
108
+ UpdateDate: null, UpdatingIDUser: 0, Deleted: 0,
109
+ DeleteDate: null, DeletingIDUser: 0,
110
+ Title: '', Type: '', Genre: '', PublicationYear: 0
111
+ },
112
+ JsonSchema:
113
+ {
114
+ title: 'Book',
115
+ type: 'object',
116
+ properties:
117
+ {
118
+ IDBook: { type: 'integer' },
119
+ GUIDBook: { type: 'string' },
120
+ CreateDate: { type: 'string' },
121
+ CreatingIDUser: { type: 'integer' },
122
+ UpdateDate: { type: 'string' },
123
+ UpdatingIDUser: { type: 'integer' },
124
+ Deleted: { type: 'boolean' },
125
+ DeleteDate: { type: 'string' },
126
+ DeletingIDUser: { type: 'integer' },
127
+ Title: { type: 'string' },
128
+ Type: { type: 'string' },
129
+ Genre: { type: 'string' },
130
+ PublicationYear: { type: 'integer' }
131
+ },
132
+ required: ['IDBook']
133
+ }
134
+ }
135
+ }
136
+ }
137
+ };
138
+
139
+ // ── Deterministic Data Generator ────────────────────────────────────────────────
140
+
141
+ const GENRES = ['Adventure', 'Mystery', 'Science', 'Romance', 'Technology',
142
+ 'Fantasy', 'History', 'Biography', 'Horror', 'Comedy'];
143
+
144
+ function generateBooks(pCount, pBaseUpdateDate)
145
+ {
146
+ let tmpBooks = [];
147
+ for (let i = 1; i <= pCount; i++)
148
+ {
149
+ tmpBooks.push(
150
+ {
151
+ IDBook: i,
152
+ GUIDBook: `GUID-BOOK-${i}`,
153
+ CreateDate: '2025-01-01T00:00:00.000Z',
154
+ CreatingIDUser: 1,
155
+ UpdateDate: pBaseUpdateDate,
156
+ UpdatingIDUser: 1,
157
+ Deleted: 0,
158
+ DeleteDate: '',
159
+ DeletingIDUser: 0,
160
+ Title: `Book-${i}`,
161
+ Type: 'Fiction',
162
+ Genre: GENRES[i % GENRES.length],
163
+ PublicationYear: 2000 + (i % 26)
164
+ });
165
+ }
166
+ return tmpBooks;
167
+ }
168
+
169
+ function mutateBooks(pBooks, pStartID, pEndID, pNewUpdateDate, pTitlePrefix)
170
+ {
171
+ for (let i = 0; i < pBooks.length; i++)
172
+ {
173
+ if (pBooks[i].IDBook >= pStartID && pBooks[i].IDBook <= pEndID)
174
+ {
175
+ pBooks[i].UpdateDate = pNewUpdateDate;
176
+ if (pTitlePrefix)
177
+ {
178
+ pBooks[i].Title = `${pTitlePrefix}-${pBooks[i].IDBook}`;
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ // Apply scattered updates: every SCATTERED_UPDATE_INTERVAL-th ID
185
+ function applyScatteredUpdates(pBooks)
186
+ {
187
+ for (let i = 0; i < pBooks.length; i++)
188
+ {
189
+ if (pBooks[i].IDBook % SCATTERED_UPDATE_INTERVAL === 0 && pBooks[i].Deleted === 0)
190
+ {
191
+ pBooks[i].UpdateDate = NEWER_UPDATE_DATE;
192
+ pBooks[i].Title = `Scattered-${pBooks[i].IDBook}`;
193
+ }
194
+ }
195
+ }
196
+
197
+ // Build the full fragmented server dataset
198
+ function buildFragmentedServerData()
199
+ {
200
+ // 50,000 active records
201
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
202
+
203
+ // Scattered updates (every 250th ID gets a newer UpdateDate)
204
+ applyScatteredUpdates(tmpBooks);
205
+
206
+ // 500 new records at the tail
207
+ for (let i = NEW_RECORDS_START; i <= NEW_RECORDS_END; i++)
208
+ {
209
+ tmpBooks.push(
210
+ {
211
+ IDBook: i,
212
+ GUIDBook: `GUID-BOOK-${i}`,
213
+ CreateDate: '2025-07-01T00:00:00.000Z',
214
+ CreatingIDUser: 1,
215
+ UpdateDate: NEWEST_UPDATE_DATE,
216
+ UpdatingIDUser: 1,
217
+ Deleted: 0,
218
+ DeleteDate: '',
219
+ DeletingIDUser: 0,
220
+ Title: `NewBook-${i}`,
221
+ Type: 'Fiction',
222
+ Genre: GENRES[i % GENRES.length],
223
+ PublicationYear: 2000 + (i % 26)
224
+ });
225
+ }
226
+
227
+ // 100 deleted records
228
+ for (let i = DELETED_START; i <= DELETED_END; i++)
229
+ {
230
+ tmpBooks.push(
231
+ {
232
+ IDBook: i,
233
+ GUIDBook: `GUID-BOOK-${i}`,
234
+ CreateDate: '2025-01-01T00:00:00.000Z',
235
+ CreatingIDUser: 1,
236
+ UpdateDate: '2025-03-01T00:00:00.000Z',
237
+ UpdatingIDUser: 1,
238
+ Deleted: 1,
239
+ DeleteDate: '2025-04-01T00:00:00.000Z',
240
+ DeletingIDUser: 1,
241
+ Title: `Deleted-Book-${i}`,
242
+ Type: 'Fiction',
243
+ Genre: GENRES[i % GENRES.length],
244
+ PublicationYear: 2000 + (i % 26)
245
+ });
246
+ }
247
+
248
+ return tmpBooks;
249
+ }
250
+
251
+ // Build local dataset: 50,000 records minus the gap (IDs 20001-20500)
252
+ function buildLocalData()
253
+ {
254
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
255
+ return tmpBooks.filter((b) => b.IDBook < GAP_START || b.IDBook > GAP_END);
256
+ }
257
+
258
+ // ── FBV~ Filter Parser ──────────────────────────────────────────────────────────
259
+
260
+ function parseFilter(pFilterString)
261
+ {
262
+ if (!pFilterString) return { filters: [], sort: null };
263
+
264
+ let tmpFilterPart = pFilterString;
265
+ let tmpSort = null;
266
+
267
+ let tmpFSFIndex = tmpFilterPart.indexOf('~FSF~');
268
+ if (tmpFSFIndex >= 0)
269
+ {
270
+ let tmpSortPart = tmpFilterPart.substring(tmpFSFIndex + 5);
271
+ tmpFilterPart = tmpFilterPart.substring(0, tmpFSFIndex);
272
+ let tmpSortTokens = tmpSortPart.split('~');
273
+ if (tmpSortTokens.length >= 2)
274
+ {
275
+ tmpSort = { Column: tmpSortTokens[0], Direction: tmpSortTokens[1] };
276
+ }
277
+ }
278
+
279
+ if (tmpFilterPart.indexOf('FSF~') === 0)
280
+ {
281
+ let tmpSortTokens = tmpFilterPart.substring(4).split('~');
282
+ if (tmpSortTokens.length >= 2)
283
+ {
284
+ tmpSort = { Column: tmpSortTokens[0], Direction: tmpSortTokens[1] };
285
+ }
286
+ return { filters: [], sort: tmpSort };
287
+ }
288
+
289
+ let tmpFilters = [];
290
+ if (tmpFilterPart.indexOf('FBV~') === 0)
291
+ {
292
+ tmpFilterPart = tmpFilterPart.substring(4);
293
+ }
294
+ let tmpClauses = tmpFilterPart.split('~FBV~');
295
+ for (let i = 0; i < tmpClauses.length; i++)
296
+ {
297
+ let tmpTokens = tmpClauses[i].split('~');
298
+ if (tmpTokens.length >= 3)
299
+ {
300
+ tmpFilters.push({ Column: tmpTokens[0], Operator: tmpTokens[1], Value: tmpTokens.slice(2).join('~') });
301
+ }
302
+ }
303
+
304
+ return { filters: tmpFilters, sort: tmpSort };
305
+ }
306
+
307
+ function applyFilters(pBooks, pParsed)
308
+ {
309
+ let tmpResult = pBooks;
310
+
311
+ for (let i = 0; i < pParsed.filters.length; i++)
312
+ {
313
+ let tmpFilter = pParsed.filters[i];
314
+ let tmpCol = tmpFilter.Column;
315
+ let tmpOp = tmpFilter.Operator;
316
+ let tmpVal = tmpFilter.Value;
317
+
318
+ tmpResult = tmpResult.filter((pBook) =>
319
+ {
320
+ let tmpBookVal = pBook[tmpCol];
321
+ if (tmpBookVal === undefined || tmpBookVal === null) return false;
322
+
323
+ let tmpCompareBookVal = String(tmpBookVal).replace(/Z$/, '');
324
+ let tmpCompareFilterVal = String(tmpVal).replace(/Z$/, '');
325
+
326
+ if (tmpCol === 'IDBook' || tmpCol === 'CreatingIDUser' || tmpCol === 'UpdatingIDUser' ||
327
+ tmpCol === 'DeletingIDUser' || tmpCol === 'PublicationYear' || tmpCol === 'Deleted')
328
+ {
329
+ tmpCompareBookVal = Number(tmpBookVal);
330
+ tmpCompareFilterVal = Number(tmpVal);
331
+ }
332
+
333
+ switch (tmpOp)
334
+ {
335
+ case 'GE': return tmpCompareBookVal >= tmpCompareFilterVal;
336
+ case 'LE': return tmpCompareBookVal <= tmpCompareFilterVal;
337
+ case 'GT': return tmpCompareBookVal > tmpCompareFilterVal;
338
+ case 'LT': return tmpCompareBookVal < tmpCompareFilterVal;
339
+ case 'EQ': return tmpCompareBookVal == tmpCompareFilterVal;
340
+ default: return true;
341
+ }
342
+ });
343
+ }
344
+
345
+ if (pParsed.sort)
346
+ {
347
+ let tmpSortCol = pParsed.sort.Column;
348
+ let tmpDir = (pParsed.sort.Direction || '').toUpperCase() === 'DESC' ? -1 : 1;
349
+ tmpResult.sort((a, b) =>
350
+ {
351
+ let tmpA = a[tmpSortCol];
352
+ let tmpB = b[tmpSortCol];
353
+ if (tmpA < tmpB) return -1 * tmpDir;
354
+ if (tmpA > tmpB) return 1 * tmpDir;
355
+ return 0;
356
+ });
357
+ }
358
+
359
+ return tmpResult;
360
+ }
361
+
362
+ // ── Mock HTTP Server (Filter-Aware) ─────────────────────────────────────────────
363
+
364
+ let _MockServerData =
365
+ {
366
+ Books: [],
367
+ RequestLog:
368
+ {
369
+ maxIDRequests: 0,
370
+ countRequests: 0,
371
+ countFilteredRequests: 0,
372
+ recordPullRequests: 0,
373
+ totalRecordsPulled: 0
374
+ }
375
+ };
376
+
377
+ function resetRequestLog()
378
+ {
379
+ _MockServerData.RequestLog =
380
+ {
381
+ maxIDRequests: 0,
382
+ countRequests: 0,
383
+ countFilteredRequests: 0,
384
+ recordPullRequests: 0,
385
+ totalRecordsPulled: 0
386
+ };
387
+ }
388
+
389
+ function createMockServer()
390
+ {
391
+ return libHTTP.createServer(
392
+ (pRequest, pResponse) =>
393
+ {
394
+ let tmpURL = pRequest.url.split('?')[0];
395
+ pResponse.setHeader('Content-Type', 'application/json');
396
+
397
+ let tmpBooks = _MockServerData.Books;
398
+
399
+ // GET /1.0/Book/Max/IDBook
400
+ if (tmpURL.match(/\/1\.0\/Book\/Max\/IDBook$/))
401
+ {
402
+ _MockServerData.RequestLog.maxIDRequests++;
403
+ let tmpMaxID = 0;
404
+ for (let i = 0; i < tmpBooks.length; i++)
405
+ {
406
+ if (tmpBooks[i].IDBook > tmpMaxID && tmpBooks[i].Deleted === 0)
407
+ {
408
+ tmpMaxID = tmpBooks[i].IDBook;
409
+ }
410
+ }
411
+ pResponse.end(JSON.stringify({ IDBook: tmpMaxID }));
412
+ return;
413
+ }
414
+
415
+ // GET /1.0/Book/Max/UpdateDate
416
+ if (tmpURL.match(/\/1\.0\/Book\/Max\/UpdateDate$/))
417
+ {
418
+ let tmpMaxDate = '';
419
+ for (let i = 0; i < tmpBooks.length; i++)
420
+ {
421
+ if (tmpBooks[i].UpdateDate > tmpMaxDate) tmpMaxDate = tmpBooks[i].UpdateDate;
422
+ }
423
+ pResponse.end(JSON.stringify({ UpdateDate: tmpMaxDate }));
424
+ return;
425
+ }
426
+
427
+ // GET /1.0/Books/Count (unfiltered)
428
+ if (tmpURL.match(/\/1\.0\/Books\/Count$/) && !tmpURL.match(/FilteredTo/))
429
+ {
430
+ _MockServerData.RequestLog.countRequests++;
431
+ // Unfiltered count returns only non-deleted records (meadow default)
432
+ let tmpCount = 0;
433
+ for (let i = 0; i < tmpBooks.length; i++)
434
+ {
435
+ if (tmpBooks[i].Deleted === 0) tmpCount++;
436
+ }
437
+ pResponse.end(JSON.stringify({ Count: tmpCount }));
438
+ return;
439
+ }
440
+
441
+ // GET /1.0/Books/Count/FilteredTo/<filter>
442
+ let tmpCountFilterMatch = tmpURL.match(/\/1\.0\/Books\/Count\/FilteredTo\/(.+)$/);
443
+ if (tmpCountFilterMatch)
444
+ {
445
+ _MockServerData.RequestLog.countFilteredRequests++;
446
+ let tmpParsed = parseFilter(tmpCountFilterMatch[1]);
447
+
448
+ // Check if the filter explicitly asks for Deleted records
449
+ let tmpExplicitDeleteFilter = tmpParsed.filters.some(
450
+ (f) => f.Column === 'Deleted');
451
+
452
+ let tmpFiltered;
453
+ if (tmpExplicitDeleteFilter)
454
+ {
455
+ tmpFiltered = applyFilters(tmpBooks, tmpParsed);
456
+ }
457
+ else
458
+ {
459
+ // Default: exclude deleted records
460
+ let tmpActive = tmpBooks.filter((b) => b.Deleted === 0);
461
+ tmpFiltered = applyFilters(tmpActive, tmpParsed);
462
+ }
463
+ pResponse.end(JSON.stringify({ Count: tmpFiltered.length }));
464
+ return;
465
+ }
466
+
467
+ // GET /1.0/Books/FilteredTo/<filter>/<offset>/<pageSize>
468
+ let tmpRecordsFilterMatch = tmpURL.match(/\/1\.0\/Books\/FilteredTo\/(.+)\/(\d+)\/(\d+)$/);
469
+ if (tmpRecordsFilterMatch)
470
+ {
471
+ _MockServerData.RequestLog.recordPullRequests++;
472
+ let tmpFilter = tmpRecordsFilterMatch[1];
473
+ let tmpOffset = parseInt(tmpRecordsFilterMatch[2]);
474
+ let tmpPageSize = parseInt(tmpRecordsFilterMatch[3]);
475
+ let tmpParsed = parseFilter(tmpFilter);
476
+
477
+ // Check if the filter explicitly asks for Deleted records
478
+ let tmpExplicitDeleteFilter = tmpParsed.filters.some(
479
+ (f) => f.Column === 'Deleted');
480
+
481
+ let tmpFiltered;
482
+ if (tmpExplicitDeleteFilter)
483
+ {
484
+ tmpFiltered = applyFilters(tmpBooks, tmpParsed);
485
+ }
486
+ else
487
+ {
488
+ let tmpActive = tmpBooks.filter((b) => b.Deleted === 0);
489
+ tmpFiltered = applyFilters(tmpActive, tmpParsed);
490
+ }
491
+
492
+ let tmpPage = tmpFiltered.slice(tmpOffset, tmpOffset + tmpPageSize);
493
+ _MockServerData.RequestLog.totalRecordsPulled += tmpPage.length;
494
+ pResponse.end(JSON.stringify(tmpPage));
495
+ return;
496
+ }
497
+
498
+ // Fallback
499
+ pResponse.statusCode = 404;
500
+ pResponse.end(JSON.stringify({ Error: `Unknown endpoint: ${tmpURL}` }));
501
+ });
502
+ }
503
+
504
+ // ── Test Helpers ────────────────────────────────────────────────────────────────
505
+
506
+ function createTestFable()
507
+ {
508
+ let tmpFable = new libFable(
509
+ {
510
+ Product: 'NewStrategiesTest',
511
+ ProductVersion: '1.0.0',
512
+ MeadowProvider: 'SQLite',
513
+ SQLite: { SQLiteFilePath: ':memory:' },
514
+ LogStreams: [{ streamtype: 'console', level: 'error' }]
515
+ });
516
+
517
+ tmpFable.ProgramConfiguration = {};
518
+
519
+ return tmpFable;
520
+ }
521
+
522
+ function setupSQLiteProvider(pFable, fCallback)
523
+ {
524
+ pFable.serviceManager.addServiceType('MeadowSQLiteProvider', libMeadowConnectionSQLite);
525
+ pFable.serviceManager.instantiateServiceProvider('MeadowSQLiteProvider');
526
+ pFable.MeadowSQLiteProvider.connectAsync(
527
+ (pError) =>
528
+ {
529
+ if (pError) return fCallback(pError);
530
+
531
+ pFable.MeadowSQLiteProvider.db.exec(`
532
+ CREATE TABLE IF NOT EXISTS Book (
533
+ IDBook INTEGER PRIMARY KEY AUTOINCREMENT,
534
+ GUIDBook TEXT DEFAULT '',
535
+ CreateDate TEXT DEFAULT '',
536
+ CreatingIDUser INTEGER DEFAULT 0,
537
+ UpdateDate TEXT DEFAULT '',
538
+ UpdatingIDUser INTEGER DEFAULT 0,
539
+ Deleted INTEGER DEFAULT 0,
540
+ DeleteDate TEXT DEFAULT '',
541
+ DeletingIDUser INTEGER DEFAULT 0,
542
+ Title TEXT DEFAULT '',
543
+ Type TEXT DEFAULT '',
544
+ Genre TEXT DEFAULT '',
545
+ PublicationYear INTEGER DEFAULT 0
546
+ );
547
+ `);
548
+
549
+ return fCallback();
550
+ });
551
+ }
552
+
553
+ function seedLocalBooks(pFable, pBooks)
554
+ {
555
+ const tmpInsert = pFable.MeadowSQLiteProvider.db.prepare(`
556
+ INSERT OR REPLACE INTO Book (IDBook, GUIDBook, CreateDate, CreatingIDUser, UpdateDate, UpdatingIDUser, Deleted, DeleteDate, DeletingIDUser, Title, Type, Genre, PublicationYear)
557
+ VALUES (@IDBook, @GUIDBook, @CreateDate, @CreatingIDUser, @UpdateDate, @UpdatingIDUser, @Deleted, @DeleteDate, @DeletingIDUser, @Title, @Type, @Genre, @PublicationYear)
558
+ `);
559
+ const tmpInsertMany = pFable.MeadowSQLiteProvider.db.transaction((pRecords) =>
560
+ {
561
+ for (const tmpRecord of pRecords)
562
+ {
563
+ tmpInsert.run(tmpRecord);
564
+ }
565
+ });
566
+ tmpInsertMany(pBooks);
567
+ }
568
+
569
+ function getLocalBookCount(pFable)
570
+ {
571
+ return pFable.MeadowSQLiteProvider.db
572
+ .prepare('SELECT COUNT(*) AS cnt FROM Book WHERE Deleted = 0')
573
+ .get().cnt;
574
+ }
575
+
576
+ function getLocalBookCountAll(pFable)
577
+ {
578
+ return pFable.MeadowSQLiteProvider.db
579
+ .prepare('SELECT COUNT(*) AS cnt FROM Book')
580
+ .get().cnt;
581
+ }
582
+
583
+ function getLocalBook(pFable, pID)
584
+ {
585
+ return pFable.MeadowSQLiteProvider.db
586
+ .prepare('SELECT * FROM Book WHERE IDBook = ?')
587
+ .get(pID);
588
+ }
589
+
590
+ function setupSyncServices(pFable, pSyncMode, fCallback, pExtraOptions)
591
+ {
592
+ if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowCloneRestClient'))
593
+ {
594
+ pFable.serviceManager.addServiceType('MeadowCloneRestClient', libMeadowCloneRestClient);
595
+ }
596
+ pFable.serviceManager.instantiateServiceProvider('MeadowCloneRestClient',
597
+ {
598
+ ServerURL: MOCK_BASE_URL
599
+ });
600
+
601
+ if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSync'))
602
+ {
603
+ pFable.serviceManager.addServiceType('MeadowSync', libMeadowSync);
604
+ }
605
+ if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSyncEntityInitial'))
606
+ {
607
+ pFable.serviceManager.addServiceType('MeadowSyncEntityInitial', libMeadowSyncEntityInitial);
608
+ }
609
+ if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSyncEntityOngoing'))
610
+ {
611
+ pFable.serviceManager.addServiceType('MeadowSyncEntityOngoing', libMeadowSyncEntityOngoing);
612
+ }
613
+ if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSyncEntityOngoingEventualConsistency'))
614
+ {
615
+ pFable.serviceManager.addServiceType('MeadowSyncEntityOngoingEventualConsistency', libMeadowSyncEntityOngoingEventualConsistency);
616
+ }
617
+ if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSyncEntityTrueUp'))
618
+ {
619
+ pFable.serviceManager.addServiceType('MeadowSyncEntityTrueUp', libMeadowSyncEntityTrueUp);
620
+ }
621
+ if (!pFable.serviceManager.servicesMap.hasOwnProperty('MeadowSyncEntityComparisonOnly'))
622
+ {
623
+ pFable.serviceManager.addServiceType('MeadowSyncEntityComparisonOnly', libMeadowSyncEntityComparisonOnly);
624
+ }
625
+
626
+ let tmpSyncOptions =
627
+ {
628
+ PageSize: 100,
629
+ BisectMinRangeSize: BISECT_MIN_RANGE
630
+ };
631
+
632
+ if (pExtraOptions)
633
+ {
634
+ Object.assign(tmpSyncOptions, pExtraOptions);
635
+ }
636
+
637
+ pFable.serviceManager.instantiateServiceProvider('MeadowSync', tmpSyncOptions);
638
+
639
+ pFable.MeadowSync.SyncMode = pSyncMode;
640
+
641
+ pFable.MeadowSync.loadMeadowSchema(_BookExtendedSchema,
642
+ (pError) =>
643
+ {
644
+ return fCallback(pError);
645
+ });
646
+ }
647
+
648
+ // ── Test Suite ──────────────────────────────────────────────────────────────────
649
+
650
+ suite
651
+ (
652
+ 'New Sync Strategies (50k records)',
653
+ () =>
654
+ {
655
+ let _MockServer = null;
656
+
657
+ suiteSetup
658
+ (
659
+ function (fDone)
660
+ {
661
+ this.timeout(10000);
662
+ _MockServer = createMockServer();
663
+ _MockServer.listen(MOCK_PORT, () => { return fDone(); });
664
+ }
665
+ );
666
+
667
+ suiteTeardown
668
+ (
669
+ (fDone) =>
670
+ {
671
+ if (_MockServer)
672
+ {
673
+ _MockServer.close(fDone);
674
+ }
675
+ else
676
+ {
677
+ return fDone();
678
+ }
679
+ }
680
+ );
681
+
682
+ // ════════════════════════════════════════════════════════════════════
683
+ // OngoingEventualConsistency
684
+ // ════════════════════════════════════════════════════════════════════
685
+
686
+ suite
687
+ (
688
+ 'OngoingEventualConsistency',
689
+ () =>
690
+ {
691
+ test
692
+ (
693
+ 'Short time budget (100ms) — should always pull new tail records regardless of budget',
694
+ function (fDone)
695
+ {
696
+ this.timeout(300000);
697
+
698
+ _MockServerData.Books = buildFragmentedServerData();
699
+ let tmpLocalBooks = buildLocalData();
700
+
701
+ let tmpFable = createTestFable();
702
+ setupSQLiteProvider(tmpFable,
703
+ (pError) =>
704
+ {
705
+ Expect(pError).to.not.exist;
706
+ seedLocalBooks(tmpFable, tmpLocalBooks);
707
+
708
+ let tmpLocalCountBefore = getLocalBookCount(tmpFable);
709
+ Expect(tmpLocalCountBefore).to.equal(RECORD_COUNT - GAP_SIZE);
710
+
711
+ resetRequestLog();
712
+
713
+ setupSyncServices(tmpFable, 'OngoingEventualConsistency',
714
+ (pError) =>
715
+ {
716
+ Expect(pError).to.not.exist;
717
+ tmpFable.MeadowSync.syncAll(
718
+ (pSyncError) =>
719
+ {
720
+ Expect(pSyncError).to.not.exist;
721
+
722
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
723
+
724
+ // New tail records (50001-50500) should ALWAYS be pulled
725
+ // regardless of the time budget
726
+ Expect(tmpEntity.syncResults.Created).to.be.at.least(NEW_RECORDS_COUNT,
727
+ 'All new tail records should be created regardless of time budget');
728
+
729
+ // Verify a new tail record exists
730
+ let tmpNewBook = getLocalBook(tmpFable, NEW_RECORDS_START + 10);
731
+ Expect(tmpNewBook).to.not.be.undefined;
732
+ Expect(tmpNewBook.Title).to.equal(`NewBook-${NEW_RECORDS_START + 10}`);
733
+
734
+ // With only 100ms budget, we should NOT have synced everything
735
+ // (the gap of 500 records + 200 scattered updates would take much longer)
736
+ // But some back-sync work should have been done
737
+ let tmpTotalSynced = tmpEntity.syncResults.Created + tmpEntity.syncResults.Updated;
738
+ Expect(tmpTotalSynced).to.be.at.least(NEW_RECORDS_COUNT,
739
+ 'Should have synced at least the new records');
740
+
741
+ return fDone();
742
+ });
743
+ }, { BackSyncTimeLimit: 100 });
744
+ });
745
+ }
746
+ );
747
+
748
+ test
749
+ (
750
+ 'Unlimited time budget — should fully sync all fragmentation',
751
+ function (fDone)
752
+ {
753
+ this.timeout(300000);
754
+
755
+ _MockServerData.Books = buildFragmentedServerData();
756
+ let tmpLocalBooks = buildLocalData();
757
+
758
+ let tmpFable = createTestFable();
759
+ setupSQLiteProvider(tmpFable,
760
+ (pError) =>
761
+ {
762
+ Expect(pError).to.not.exist;
763
+ seedLocalBooks(tmpFable, tmpLocalBooks);
764
+
765
+ resetRequestLog();
766
+
767
+ setupSyncServices(tmpFable, 'OngoingEventualConsistency',
768
+ (pError) =>
769
+ {
770
+ Expect(pError).to.not.exist;
771
+ tmpFable.MeadowSync.syncAll(
772
+ (pSyncError) =>
773
+ {
774
+ Expect(pSyncError).to.not.exist;
775
+
776
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
777
+
778
+ // Gap records (20001-20500) should be created
779
+ Expect(tmpEntity.syncResults.Created).to.be.at.least(GAP_SIZE + NEW_RECORDS_COUNT,
780
+ `Should create at least ${GAP_SIZE} gap records + ${NEW_RECORDS_COUNT} new records`);
781
+
782
+ // Scattered updates should be applied
783
+ Expect(tmpEntity.syncResults.Updated).to.be.at.least(SCATTERED_UPDATE_COUNT,
784
+ `Should update at least ${SCATTERED_UPDATE_COUNT} scattered records`);
785
+
786
+ // Verify specific gap record
787
+ let tmpGapBook = getLocalBook(tmpFable, GAP_START + 50);
788
+ Expect(tmpGapBook).to.not.be.undefined;
789
+ Expect(tmpGapBook.Title).to.equal(`Book-${GAP_START + 50}`);
790
+
791
+ // Verify a scattered update (every 5000th ID)
792
+ let tmpScatteredBook = getLocalBook(tmpFable, 5000);
793
+ Expect(tmpScatteredBook.Title).to.equal('Scattered-5000');
794
+
795
+ // Verify new tail record
796
+ let tmpTailBook = getLocalBook(tmpFable, NEW_RECORDS_END);
797
+ Expect(tmpTailBook).to.not.be.undefined;
798
+ Expect(tmpTailBook.Title).to.equal(`NewBook-${NEW_RECORDS_END}`);
799
+
800
+ return fDone();
801
+ });
802
+ }, { BackSyncTimeLimit: 999999 });
803
+ });
804
+ }
805
+ );
806
+
807
+ test
808
+ (
809
+ 'Backwards bisection should prioritize high IDs over low IDs',
810
+ function (fDone)
811
+ {
812
+ this.timeout(300000);
813
+
814
+ // Create a clean dataset with changes at both ends
815
+ let tmpServerBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
816
+ // Mutate 50 records near the END
817
+ mutateBooks(tmpServerBooks, 49900, 49950, NEWER_UPDATE_DATE, 'HighEnd');
818
+ // Mutate 50 records near the START
819
+ mutateBooks(tmpServerBooks, 100, 150, NEWER_UPDATE_DATE, 'LowEnd');
820
+ _MockServerData.Books = tmpServerBooks;
821
+
822
+ let tmpLocalBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
823
+
824
+ let tmpFable = createTestFable();
825
+ setupSQLiteProvider(tmpFable,
826
+ (pError) =>
827
+ {
828
+ Expect(pError).to.not.exist;
829
+ seedLocalBooks(tmpFable, tmpLocalBooks);
830
+
831
+ resetRequestLog();
832
+
833
+ setupSyncServices(tmpFable, 'OngoingEventualConsistency',
834
+ (pError) =>
835
+ {
836
+ Expect(pError).to.not.exist;
837
+ tmpFable.MeadowSync.syncAll(
838
+ (pSyncError) =>
839
+ {
840
+ Expect(pSyncError).to.not.exist;
841
+
842
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
843
+
844
+ // With a very short budget, the high-end changes should
845
+ // be prioritized because backwards bisection starts from
846
+ // the upper half
847
+ let tmpHighEndSynced = getLocalBook(tmpFable, 49925);
848
+ Expect(tmpHighEndSynced.Title).to.equal('HighEnd-49925',
849
+ 'High-end records should be prioritized by backwards bisection');
850
+
851
+ return fDone();
852
+ });
853
+ }, { BackSyncTimeLimit: 100 });
854
+ });
855
+ }
856
+ );
857
+ }
858
+ );
859
+
860
+ // ════════════════════════════════════════════════════════════════════
861
+ // TrueUp
862
+ // ════════════════════════════════════════════════════════════════════
863
+
864
+ suite
865
+ (
866
+ 'TrueUp',
867
+ () =>
868
+ {
869
+ test
870
+ (
871
+ 'Full true-up with mixed fragmentation — should sync all differences',
872
+ function (fDone)
873
+ {
874
+ this.timeout(300000);
875
+
876
+ _MockServerData.Books = buildFragmentedServerData();
877
+ let tmpLocalBooks = buildLocalData();
878
+
879
+ let tmpFable = createTestFable();
880
+ setupSQLiteProvider(tmpFable,
881
+ (pError) =>
882
+ {
883
+ Expect(pError).to.not.exist;
884
+ seedLocalBooks(tmpFable, tmpLocalBooks);
885
+
886
+ Expect(getLocalBookCount(tmpFable)).to.equal(RECORD_COUNT - GAP_SIZE);
887
+
888
+ resetRequestLog();
889
+
890
+ setupSyncServices(tmpFable, 'TrueUp',
891
+ (pError) =>
892
+ {
893
+ Expect(pError).to.not.exist;
894
+ tmpFable.MeadowSync.syncAll(
895
+ (pSyncError) =>
896
+ {
897
+ Expect(pSyncError).to.not.exist;
898
+
899
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
900
+
901
+ // All gap records + new tail records should be created
902
+ Expect(tmpEntity.syncResults.Created).to.be.at.least(GAP_SIZE + NEW_RECORDS_COUNT,
903
+ `Should create at least ${GAP_SIZE + NEW_RECORDS_COUNT} records (gap + new)`);
904
+
905
+ // Scattered updates should be applied
906
+ Expect(tmpEntity.syncResults.Updated).to.be.at.least(SCATTERED_UPDATE_COUNT,
907
+ `Should update at least ${SCATTERED_UPDATE_COUNT} scattered records`);
908
+
909
+ // Final count: 50,000 original + 500 new = 50,500 active
910
+ let tmpFinalCount = getLocalBookCount(tmpFable);
911
+ Expect(tmpFinalCount).to.equal(RECORD_COUNT + NEW_RECORDS_COUNT);
912
+
913
+ // Verify gap was filled
914
+ let tmpGapBook = getLocalBook(tmpFable, GAP_START + 100);
915
+ Expect(tmpGapBook).to.not.be.undefined;
916
+ Expect(tmpGapBook.Title).to.equal(`Book-${GAP_START + 100}`);
917
+
918
+ // Verify scattered update applied (every 5000th ID)
919
+ let tmpScattered = getLocalBook(tmpFable, 10000);
920
+ Expect(tmpScattered.Title).to.equal('Scattered-10000');
921
+
922
+ // Verify new tail record
923
+ let tmpNewBook = getLocalBook(tmpFable, NEW_RECORDS_END - 5);
924
+ Expect(tmpNewBook).to.not.be.undefined;
925
+
926
+ return fDone();
927
+ });
928
+ }, { TrueUpPageSize: 500 });
929
+ });
930
+ }
931
+ );
932
+
933
+ test
934
+ (
935
+ 'TrueUp with deleted records enabled',
936
+ function (fDone)
937
+ {
938
+ this.timeout(300000);
939
+
940
+ _MockServerData.Books = buildFragmentedServerData();
941
+ let tmpLocalBooks = buildLocalData();
942
+
943
+ let tmpFable = createTestFable();
944
+ setupSQLiteProvider(tmpFable,
945
+ (pError) =>
946
+ {
947
+ Expect(pError).to.not.exist;
948
+ seedLocalBooks(tmpFable, tmpLocalBooks);
949
+
950
+ resetRequestLog();
951
+
952
+ setupSyncServices(tmpFable, 'TrueUp',
953
+ (pError) =>
954
+ {
955
+ Expect(pError).to.not.exist;
956
+ tmpFable.MeadowSync.syncAll(
957
+ (pSyncError) =>
958
+ {
959
+ Expect(pSyncError).to.not.exist;
960
+
961
+ // Check that deleted records were synced
962
+ let tmpDeletedBook = getLocalBook(tmpFable, DELETED_START + 5);
963
+ Expect(tmpDeletedBook).to.not.be.undefined;
964
+ Expect(tmpDeletedBook.Deleted).to.equal(1);
965
+ Expect(tmpDeletedBook.Title).to.equal(`Deleted-Book-${DELETED_START + 5}`);
966
+
967
+ // Total including deleted
968
+ let tmpTotalAll = getLocalBookCountAll(tmpFable);
969
+ Expect(tmpTotalAll).to.be.at.least(RECORD_COUNT + NEW_RECORDS_COUNT + DELETED_COUNT);
970
+
971
+ return fDone();
972
+ });
973
+ }, { TrueUpPageSize: 500, SyncDeletedRecords: true });
974
+ });
975
+ }
976
+ );
977
+
978
+ test
979
+ (
980
+ 'TrueUp idempotency — second run should create zero new records',
981
+ function (fDone)
982
+ {
983
+ this.timeout(600000);
984
+
985
+ // Use a simpler dataset for idempotency (avoid timing out)
986
+ let tmpServerBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
987
+ applyScatteredUpdates(tmpServerBooks);
988
+ _MockServerData.Books = tmpServerBooks;
989
+
990
+ let tmpLocalBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
991
+
992
+ let tmpFable = createTestFable();
993
+ setupSQLiteProvider(tmpFable,
994
+ (pError) =>
995
+ {
996
+ Expect(pError).to.not.exist;
997
+ seedLocalBooks(tmpFable, tmpLocalBooks);
998
+
999
+ // First true-up
1000
+ setupSyncServices(tmpFable, 'TrueUp',
1001
+ (pError) =>
1002
+ {
1003
+ Expect(pError).to.not.exist;
1004
+ tmpFable.MeadowSync.syncAll(
1005
+ (pSyncError) =>
1006
+ {
1007
+ Expect(pSyncError).to.not.exist;
1008
+
1009
+ let tmpEntity1 = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
1010
+ Expect(tmpEntity1.syncResults.Updated).to.be.at.least(SCATTERED_UPDATE_COUNT);
1011
+
1012
+ // Second true-up — should create nothing new
1013
+ resetRequestLog();
1014
+ setupSyncServices(tmpFable, 'TrueUp',
1015
+ (pError2) =>
1016
+ {
1017
+ Expect(pError2).to.not.exist;
1018
+ tmpFable.MeadowSync.syncAll(
1019
+ (pSyncError2) =>
1020
+ {
1021
+ Expect(pSyncError2).to.not.exist;
1022
+
1023
+ let tmpEntity2 = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
1024
+ Expect(tmpEntity2.syncResults.Created).to.equal(0,
1025
+ 'Second true-up should create zero new records');
1026
+
1027
+ return fDone();
1028
+ });
1029
+ }, { TrueUpPageSize: 500 });
1030
+ });
1031
+ }, { TrueUpPageSize: 500 });
1032
+ });
1033
+ }
1034
+ );
1035
+ }
1036
+ );
1037
+
1038
+ // ════════════════════════════════════════════════════════════════════
1039
+ // ComparisonOnly
1040
+ // ════════════════════════════════════════════════════════════════════
1041
+
1042
+ suite
1043
+ (
1044
+ 'ComparisonOnly',
1045
+ () =>
1046
+ {
1047
+ test
1048
+ (
1049
+ 'Comparison report accuracy with mixed fragmentation',
1050
+ function (fDone)
1051
+ {
1052
+ this.timeout(300000);
1053
+
1054
+ _MockServerData.Books = buildFragmentedServerData();
1055
+ let tmpLocalBooks = buildLocalData();
1056
+
1057
+ let tmpFable = createTestFable();
1058
+ setupSQLiteProvider(tmpFable,
1059
+ (pError) =>
1060
+ {
1061
+ Expect(pError).to.not.exist;
1062
+ seedLocalBooks(tmpFable, tmpLocalBooks);
1063
+
1064
+ resetRequestLog();
1065
+
1066
+ setupSyncServices(tmpFable, 'ComparisonOnly',
1067
+ (pError) =>
1068
+ {
1069
+ Expect(pError).to.not.exist;
1070
+ tmpFable.MeadowSync.syncAll(
1071
+ (pSyncError) =>
1072
+ {
1073
+ Expect(pSyncError).to.not.exist;
1074
+
1075
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
1076
+
1077
+ // No records should have been synced
1078
+ Expect(tmpEntity.syncResults.Created).to.equal(0,
1079
+ 'ComparisonOnly should not create any records');
1080
+ Expect(tmpEntity.syncResults.Updated).to.equal(0,
1081
+ 'ComparisonOnly should not update any records');
1082
+
1083
+ // ComparisonReport should exist
1084
+ Expect(tmpEntity.ComparisonReport).to.be.an('object');
1085
+ let tmpReport = tmpEntity.ComparisonReport;
1086
+
1087
+ // Validate report structure
1088
+ Expect(tmpReport.Entity).to.equal('Book');
1089
+ Expect(tmpReport.Timestamp).to.be.a('string');
1090
+ Expect(tmpReport.Summary).to.be.an('object');
1091
+ Expect(tmpReport.Ranges).to.be.an('array');
1092
+
1093
+ // There should be mismatches (gap + new records + scattered updates)
1094
+ Expect(tmpReport.Summary.MismatchedRanges).to.be.above(0,
1095
+ 'Should detect mismatched ranges from gap and scattered updates');
1096
+
1097
+ // There should also be matching ranges (unchanged regions)
1098
+ Expect(tmpReport.Summary.MatchingRanges).to.be.above(0,
1099
+ 'Should detect matching ranges in unchanged regions');
1100
+
1101
+ // Range counts should add up
1102
+ Expect(tmpReport.Summary.TotalRangesChecked).to.equal(
1103
+ tmpReport.Summary.MatchingRanges +
1104
+ tmpReport.Summary.MismatchedRanges +
1105
+ tmpReport.Summary.ErrorRanges,
1106
+ 'Total ranges should equal matching + mismatched + error');
1107
+
1108
+ // Local data should be untouched
1109
+ let tmpLocalCount = getLocalBookCount(tmpFable);
1110
+ Expect(tmpLocalCount).to.equal(RECORD_COUNT - GAP_SIZE,
1111
+ 'Local record count should be unchanged after comparison');
1112
+
1113
+ return fDone();
1114
+ });
1115
+ });
1116
+ });
1117
+ }
1118
+ );
1119
+
1120
+ test
1121
+ (
1122
+ 'Comparison on identical data — should report zero mismatches',
1123
+ function (fDone)
1124
+ {
1125
+ this.timeout(300000);
1126
+
1127
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
1128
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
1129
+
1130
+ let tmpFable = createTestFable();
1131
+ setupSQLiteProvider(tmpFable,
1132
+ (pError) =>
1133
+ {
1134
+ Expect(pError).to.not.exist;
1135
+ seedLocalBooks(tmpFable, tmpBooks);
1136
+
1137
+ resetRequestLog();
1138
+
1139
+ setupSyncServices(tmpFable, 'ComparisonOnly',
1140
+ (pError) =>
1141
+ {
1142
+ Expect(pError).to.not.exist;
1143
+ tmpFable.MeadowSync.syncAll(
1144
+ (pSyncError) =>
1145
+ {
1146
+ Expect(pSyncError).to.not.exist;
1147
+
1148
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
1149
+ let tmpReport = tmpEntity.ComparisonReport;
1150
+
1151
+ Expect(tmpReport.Summary.MismatchedRanges).to.equal(0,
1152
+ 'Identical data should have zero mismatches');
1153
+ Expect(tmpReport.Summary.MatchingRanges).to.be.above(0,
1154
+ 'Should have matching ranges');
1155
+ Expect(tmpEntity.syncResults.Created).to.equal(0);
1156
+ Expect(tmpEntity.syncResults.Updated).to.equal(0);
1157
+
1158
+ return fDone();
1159
+ });
1160
+ });
1161
+ });
1162
+ }
1163
+ );
1164
+
1165
+ test
1166
+ (
1167
+ 'Report contains UpdateDate mismatch details when counts match but dates differ',
1168
+ function (fDone)
1169
+ {
1170
+ this.timeout(300000);
1171
+
1172
+ let tmpBooks = generateBooks(RECORD_COUNT, BASE_UPDATE_DATE);
1173
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
1174
+
1175
+ let tmpFable = createTestFable();
1176
+ setupSQLiteProvider(tmpFable,
1177
+ (pError) =>
1178
+ {
1179
+ Expect(pError).to.not.exist;
1180
+ seedLocalBooks(tmpFable, tmpBooks);
1181
+
1182
+ // Mutate 50 records on server (same count, different dates)
1183
+ mutateBooks(_MockServerData.Books, 5000, 5050, NEWER_UPDATE_DATE, 'DateChanged');
1184
+
1185
+ resetRequestLog();
1186
+
1187
+ setupSyncServices(tmpFable, 'ComparisonOnly',
1188
+ (pError) =>
1189
+ {
1190
+ Expect(pError).to.not.exist;
1191
+ tmpFable.MeadowSync.syncAll(
1192
+ (pSyncError) =>
1193
+ {
1194
+ Expect(pSyncError).to.not.exist;
1195
+
1196
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
1197
+ let tmpReport = tmpEntity.ComparisonReport;
1198
+
1199
+ // Should detect date mismatches
1200
+ Expect(tmpReport.Summary.MismatchedRanges).to.be.above(0,
1201
+ 'Should detect mismatches when UpdateDates differ');
1202
+
1203
+ // Find a mismatch range and verify it has UpdateDate details
1204
+ let tmpDateMismatch = tmpReport.Ranges.find(
1205
+ (r) => r.Status === 'mismatch' && r.UpdateDateDifferenceMS > 0);
1206
+ Expect(tmpDateMismatch).to.not.be.undefined;
1207
+ Expect(tmpDateMismatch.UpdateDateDifferenceMS).to.be.above(0);
1208
+ Expect(tmpDateMismatch.LocalMaxUpdateDate).to.be.a('string');
1209
+ Expect(tmpDateMismatch.ServerMaxUpdateDate).to.be.a('string');
1210
+
1211
+ // No records should be synced
1212
+ Expect(tmpEntity.syncResults.Created).to.equal(0);
1213
+ Expect(tmpEntity.syncResults.Updated).to.equal(0);
1214
+
1215
+ return fDone();
1216
+ });
1217
+ });
1218
+ });
1219
+ }
1220
+ );
1221
+
1222
+ test
1223
+ (
1224
+ 'Report stored on syncResults.ComparisonReport',
1225
+ function (fDone)
1226
+ {
1227
+ this.timeout(300000);
1228
+
1229
+ let tmpBooks = generateBooks(5000, BASE_UPDATE_DATE);
1230
+ _MockServerData.Books = tmpBooks.map((b) => Object.assign({}, b));
1231
+
1232
+ let tmpFable = createTestFable();
1233
+ setupSQLiteProvider(tmpFable,
1234
+ (pError) =>
1235
+ {
1236
+ Expect(pError).to.not.exist;
1237
+ seedLocalBooks(tmpFable, tmpBooks);
1238
+
1239
+ setupSyncServices(tmpFable, 'ComparisonOnly',
1240
+ (pError) =>
1241
+ {
1242
+ Expect(pError).to.not.exist;
1243
+ tmpFable.MeadowSync.syncAll(
1244
+ (pSyncError) =>
1245
+ {
1246
+ Expect(pSyncError).to.not.exist;
1247
+
1248
+ let tmpEntity = tmpFable.MeadowSync.MeadowSyncEntities['Book'];
1249
+
1250
+ // syncResults.ComparisonReport should reference the same report
1251
+ Expect(tmpEntity.syncResults.ComparisonReport).to.equal(tmpEntity.ComparisonReport,
1252
+ 'syncResults.ComparisonReport should reference the same report object');
1253
+ Expect(tmpEntity.syncResults.ComparisonReport.Entity).to.equal('Book');
1254
+ Expect(tmpEntity.syncResults.ComparisonReport.Summary).to.be.an('object');
1255
+
1256
+ return fDone();
1257
+ });
1258
+ });
1259
+ });
1260
+ }
1261
+ );
1262
+ }
1263
+ );
1264
+ }
1265
+ );