retold-data-service 2.0.16 → 2.0.18

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.
Files changed (28) hide show
  1. package/.claude/launch.json +2 -2
  2. package/.quackage.json +19 -0
  3. package/package.json +13 -6
  4. package/source/services/data-cloner/DataCloner-Command-Sync.js +83 -50
  5. package/source/services/data-cloner/DataCloner-Command-WebUI.js +27 -10
  6. package/source/services/data-cloner/Retold-Data-Service-DataCloner.js +281 -4
  7. package/source/services/data-cloner/pict-app/Pict-Application-DataCloner-Configuration.json +9 -0
  8. package/source/services/data-cloner/pict-app/Pict-Application-DataCloner.js +102 -0
  9. package/source/services/data-cloner/pict-app/Pict-DataCloner-Bundle.js +6 -0
  10. package/source/services/data-cloner/pict-app/providers/Pict-Provider-DataCloner.js +998 -0
  11. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Connection.js +407 -0
  12. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Deploy.js +126 -0
  13. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Export.js +483 -0
  14. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Layout.js +390 -0
  15. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Schema.js +241 -0
  16. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Session.js +268 -0
  17. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-Sync.js +575 -0
  18. package/source/services/data-cloner/pict-app/views/PictView-DataCloner-ViewData.js +176 -0
  19. package/source/services/data-cloner/web/data-cloner.js +7952 -0
  20. package/source/services/data-cloner/web/data-cloner.js.map +1 -0
  21. package/source/services/data-cloner/web/data-cloner.min.js +2 -0
  22. package/source/services/data-cloner/web/data-cloner.min.js.map +1 -0
  23. package/source/services/data-cloner/web/index.html +17 -0
  24. package/test/DataCloner-Integration_tests.js +1205 -0
  25. package/test/DataCloner-Puppeteer_tests.js +502 -0
  26. package/test/integration-report.json +311 -0
  27. package/test/run-integration-tests.js +501 -0
  28. package/source/services/data-cloner/data-cloner-web.html +0 -2706
@@ -0,0 +1,1205 @@
1
+ /**
2
+ * Data Cloner Integration Tests
3
+ *
4
+ * Exercises the full data-cloner pipeline against a live retold-harness server.
5
+ * Run via: node test/run-integration-tests.js
6
+ *
7
+ * @license MIT
8
+ * @author <steven@velozo.com>
9
+ */
10
+
11
+ var Chai = require('chai');
12
+ var Expect = Chai.expect;
13
+
14
+ var libHttp = require('http');
15
+ var libFs = require('fs');
16
+
17
+ // Ports set by run-integration-tests.js
18
+ var _ClonerPort = parseInt(process.env.CLONER_PORT, 10) || 9400;
19
+ var _HarnessPort = parseInt(process.env.HARNESS_PORT, 10) || 9403;
20
+ var _ClonerBase = `http://localhost:${_ClonerPort}`;
21
+ var _HarnessBase = `http://localhost:${_HarnessPort}`;
22
+ var _RequestedEngines = (process.env.REQUESTED_ENGINES || 'sqlite').split(',');
23
+
24
+ // Load the harness schema from disk (retold-harness doesn't serve /Retold/Models)
25
+ var _HarnessSchemaPath = process.env.HARNESS_SCHEMA_PATH || require('path').resolve(__dirname, '..', '..', 'retold-harness', 'source', 'schemas', 'bookstore', 'MeadowModel-Extended.json');
26
+ var _HarnessSchema = JSON.parse(libFs.readFileSync(_HarnessSchemaPath, 'utf8'));
27
+
28
+ // Track storage engine results for the report
29
+ var _EngineResults = {};
30
+
31
+ // ---- HTTP Helpers (same pattern as DataCloner-Command-Headless.js) ----
32
+
33
+ // Parse JSON, handling potential double-encoding from restify
34
+ var fParseJSON = function(pRaw)
35
+ {
36
+ var tmpData = JSON.parse(pRaw);
37
+ // Handle double-encoded JSON (restify sometimes wraps strings)
38
+ if (typeof tmpData === 'string')
39
+ {
40
+ try { tmpData = JSON.parse(tmpData); }
41
+ catch (e) { /* leave as string */ }
42
+ }
43
+ return tmpData;
44
+ };
45
+
46
+ var fParseResponse = function(pChunks)
47
+ {
48
+ var tmpRaw = Buffer.concat(pChunks).toString();
49
+ try
50
+ {
51
+ return fParseJSON(tmpRaw);
52
+ }
53
+ catch (pParseError)
54
+ {
55
+ return tmpRaw;
56
+ }
57
+ };
58
+
59
+ var fPost = function(pPath, pBody, fCallback)
60
+ {
61
+ var tmpPayload = JSON.stringify(pBody);
62
+ var tmpURL = new URL(_ClonerBase + pPath);
63
+ var tmpOpts =
64
+ {
65
+ hostname: tmpURL.hostname,
66
+ port: tmpURL.port,
67
+ path: tmpURL.pathname,
68
+ method: 'POST',
69
+ headers:
70
+ {
71
+ 'Content-Type': 'application/json',
72
+ 'Content-Length': Buffer.byteLength(tmpPayload)
73
+ }
74
+ };
75
+
76
+ var tmpReq = libHttp.request(tmpOpts,
77
+ (pRes) =>
78
+ {
79
+ var tmpChunks = [];
80
+ pRes.on('data', (pChunk) => tmpChunks.push(pChunk));
81
+ pRes.on('end', () =>
82
+ {
83
+ fCallback(null, fParseResponse(tmpChunks));
84
+ });
85
+ });
86
+ tmpReq.on('error', fCallback);
87
+ tmpReq.write(tmpPayload);
88
+ tmpReq.end();
89
+ };
90
+
91
+ var fGet = function(pPath, fCallback)
92
+ {
93
+ libHttp.get(_ClonerBase + pPath,
94
+ (pRes) =>
95
+ {
96
+ var tmpChunks = [];
97
+ pRes.on('data', (pChunk) => tmpChunks.push(pChunk));
98
+ pRes.on('end', () =>
99
+ {
100
+ fCallback(null, fParseResponse(tmpChunks));
101
+ });
102
+ }).on('error', fCallback);
103
+ };
104
+
105
+ var fGetHarness = function(pPath, fCallback)
106
+ {
107
+ libHttp.get(_HarnessBase + pPath,
108
+ (pRes) =>
109
+ {
110
+ var tmpChunks = [];
111
+ pRes.on('data', (pChunk) => tmpChunks.push(pChunk));
112
+ pRes.on('end', () =>
113
+ {
114
+ fCallback(null, fParseResponse(tmpChunks));
115
+ });
116
+ }).on('error', fCallback);
117
+ };
118
+
119
+ var fWaitForSyncComplete = function(fCallback, pTimeout)
120
+ {
121
+ var tmpTimeout = pTimeout || 120000;
122
+ var tmpStart = Date.now();
123
+
124
+ var fPoll = function()
125
+ {
126
+ fGet('/clone/sync/status',
127
+ (pError, pStatus) =>
128
+ {
129
+ if (pError)
130
+ {
131
+ if (Date.now() - tmpStart > tmpTimeout) return fCallback(new Error('Sync timeout (poll error)'));
132
+ return setTimeout(fPoll, 2000);
133
+ }
134
+
135
+ if (pStatus && !pStatus.Running)
136
+ {
137
+ return fCallback(null, pStatus);
138
+ }
139
+
140
+ if (Date.now() - tmpStart > tmpTimeout)
141
+ {
142
+ return fCallback(new Error('Sync timeout'));
143
+ }
144
+
145
+ setTimeout(fPoll, 2000);
146
+ });
147
+ };
148
+
149
+ setTimeout(fPoll, 1000);
150
+ };
151
+
152
+ // Full pipeline: reset → configure connection → session → schema → deploy → sync → report
153
+ var fRunFullPipeline = function(pProviderConfig, pMaxRecords, fCallback)
154
+ {
155
+ var tmpSyncStart;
156
+
157
+ // Step 1: Reset
158
+ fPost('/clone/reset', {},
159
+ (pResetErr) =>
160
+ {
161
+ // Step 2: Configure connection (skip for default SQLite)
162
+ var fAfterConnection = function()
163
+ {
164
+ // Step 3: Configure session
165
+ fPost('/clone/session/configure', { ServerURL: `${_HarnessBase}/1.0/` },
166
+ (pSessionErr, pSessionData) =>
167
+ {
168
+ if (pSessionErr || !pSessionData || !pSessionData.Success)
169
+ {
170
+ return fCallback(new Error('Session configure failed: ' + (pSessionErr || JSON.stringify(pSessionData))));
171
+ }
172
+
173
+ // Step 4: Fetch schema
174
+ fPost('/clone/schema/fetch', { Schema: _HarnessSchema },
175
+ (pSchemaErr, pSchemaData) =>
176
+ {
177
+ if (pSchemaErr || !pSchemaData || !pSchemaData.Success)
178
+ {
179
+ return fCallback(new Error('Schema fetch failed: ' + (pSchemaErr || JSON.stringify(pSchemaData))));
180
+ }
181
+
182
+ // Step 5: Deploy
183
+ fPost('/clone/schema/deploy', { Tables: [] },
184
+ (pDeployErr, pDeployData) =>
185
+ {
186
+ if (pDeployErr || !pDeployData || !pDeployData.Success)
187
+ {
188
+ return fCallback(new Error('Deploy failed: ' + (pDeployErr || JSON.stringify(pDeployData))));
189
+ }
190
+
191
+ // Step 6: Sync
192
+ tmpSyncStart = Date.now();
193
+ fPost('/clone/sync/start', { SyncMode: 'Initial', MaxRecordsPerEntity: pMaxRecords || 50 },
194
+ (pSyncErr, pSyncData) =>
195
+ {
196
+ if (pSyncErr || !pSyncData || !pSyncData.Success)
197
+ {
198
+ return fCallback(new Error('Sync start failed: ' + (pSyncErr || JSON.stringify(pSyncData))));
199
+ }
200
+
201
+ // Step 7: Wait for completion
202
+ fWaitForSyncComplete(
203
+ (pWaitErr, pFinalStatus) =>
204
+ {
205
+ if (pWaitErr)
206
+ {
207
+ return fCallback(pWaitErr);
208
+ }
209
+
210
+ var tmpSyncDuration = Date.now() - tmpSyncStart;
211
+
212
+ // Step 8: Get report
213
+ fGet('/clone/sync/report',
214
+ (pReportErr, pReport) =>
215
+ {
216
+ return fCallback(null,
217
+ {
218
+ syncDuration: tmpSyncDuration,
219
+ finalStatus: pFinalStatus,
220
+ report: pReport,
221
+ schemaData: pSchemaData,
222
+ deployData: pDeployData
223
+ });
224
+ });
225
+ });
226
+ });
227
+ });
228
+ });
229
+ });
230
+ };
231
+
232
+ if (pProviderConfig && pProviderConfig.Provider !== 'SQLite')
233
+ {
234
+ fPost('/clone/connection/configure', pProviderConfig,
235
+ (pConnErr, pConnData) =>
236
+ {
237
+ if (pConnErr || !pConnData || !pConnData.Success)
238
+ {
239
+ return fCallback(new Error('Connection configure failed: ' + (pConnErr || JSON.stringify(pConnData))));
240
+ }
241
+ fAfterConnection();
242
+ });
243
+ }
244
+ else
245
+ {
246
+ fAfterConnection();
247
+ }
248
+ });
249
+ };
250
+
251
+ // ---- Harness data cache (fetched once) ----
252
+ var _HarnessBookCount = 0;
253
+ var _HarnessAuthorCount = 0;
254
+ var _HarnessBookRecord1 = null;
255
+
256
+ // ================================================================
257
+ // TEST SUITES
258
+ // ================================================================
259
+
260
+ suite
261
+ (
262
+ 'Data Cloner Integration',
263
+ function()
264
+ {
265
+ this.timeout(120000);
266
+
267
+ // ---- Connection Management ----
268
+ suite
269
+ (
270
+ 'Connection Management',
271
+ function()
272
+ {
273
+ test
274
+ (
275
+ 'Should show initial connection status',
276
+ function(fDone)
277
+ {
278
+ fGet('/clone/connection/status',
279
+ (pError, pData) =>
280
+ {
281
+ Expect(pError).to.equal(null);
282
+ Expect(pData).to.be.an('object');
283
+ Expect(pData.Provider).to.equal('SQLite');
284
+ // Initially may or may not be connected depending on startup
285
+ fDone();
286
+ });
287
+ }
288
+ );
289
+ test
290
+ (
291
+ 'Should connect SQLite via configure',
292
+ function(fDone)
293
+ {
294
+ this.timeout(10000);
295
+ fPost('/clone/connection/configure',
296
+ {
297
+ Provider: 'SQLite',
298
+ Config: {}
299
+ },
300
+ (pError, pData) =>
301
+ {
302
+ Expect(pError).to.equal(null);
303
+ Expect(pData).to.be.an('object');
304
+ Expect(pData.Success).to.equal(true);
305
+ fDone();
306
+ });
307
+ }
308
+ );
309
+ test
310
+ (
311
+ 'Should show connected after configure',
312
+ function(fDone)
313
+ {
314
+ fGet('/clone/connection/status',
315
+ (pError, pData) =>
316
+ {
317
+ Expect(pError).to.equal(null);
318
+ Expect(pData).to.be.an('object');
319
+ Expect(pData.Provider).to.equal('SQLite');
320
+ Expect(pData.Connected).to.equal(true);
321
+ fDone();
322
+ });
323
+ }
324
+ );
325
+ }
326
+ );
327
+
328
+ // ---- Session Configuration ----
329
+ suite
330
+ (
331
+ 'Session Configuration',
332
+ function()
333
+ {
334
+ test
335
+ (
336
+ 'Should configure session with retold-harness URL',
337
+ function(fDone)
338
+ {
339
+ fPost('/clone/session/configure',
340
+ { ServerURL: `${_HarnessBase}/1.0/` },
341
+ (pError, pData) =>
342
+ {
343
+ Expect(pError).to.equal(null);
344
+ Expect(pData.Success).to.equal(true);
345
+ Expect(pData.ServerURL).to.contain(String(_HarnessPort));
346
+ fDone();
347
+ });
348
+ }
349
+ );
350
+ test
351
+ (
352
+ 'Should show session as configured',
353
+ function(fDone)
354
+ {
355
+ fGet('/clone/session/check',
356
+ (pError, pData) =>
357
+ {
358
+ Expect(pError).to.equal(null);
359
+ Expect(pData.Configured).to.equal(true);
360
+ Expect(pData.ServerURL).to.contain(String(_HarnessPort));
361
+ fDone();
362
+ });
363
+ }
364
+ );
365
+ test
366
+ (
367
+ 'Should reject session configure without ServerURL',
368
+ function(fDone)
369
+ {
370
+ fPost('/clone/session/configure', {},
371
+ (pError, pData) =>
372
+ {
373
+ Expect(pData.Success).to.equal(false);
374
+ Expect(pData.Error).to.contain('ServerURL');
375
+ fDone();
376
+ });
377
+ }
378
+ );
379
+ }
380
+ );
381
+
382
+ // ---- Schema Fetch ----
383
+ suite
384
+ (
385
+ 'Schema Fetch',
386
+ function()
387
+ {
388
+ test
389
+ (
390
+ 'Should reconfigure session for fresh schema fetch',
391
+ function(fDone)
392
+ {
393
+ fPost('/clone/session/configure',
394
+ { ServerURL: `${_HarnessBase}/1.0/` },
395
+ (pError, pData) =>
396
+ {
397
+ Expect(pData.Success).to.equal(true);
398
+ fDone();
399
+ });
400
+ }
401
+ );
402
+ test
403
+ (
404
+ 'Should fetch schema from retold-harness',
405
+ function(fDone)
406
+ {
407
+ fPost('/clone/schema/fetch', { Schema: _HarnessSchema },
408
+ (pError, pData) =>
409
+ {
410
+ Expect(pError).to.equal(null);
411
+ Expect(pData.Success).to.equal(true);
412
+ Expect(pData.TableCount).to.be.greaterThan(0);
413
+ Expect(pData.Tables).to.be.an('array');
414
+ // Bookstore schema has 8 entities
415
+ Expect(pData.Tables).to.include('Book');
416
+ Expect(pData.Tables).to.include('Author');
417
+ Expect(pData.Tables).to.include('User');
418
+ Expect(pData.Tables).to.include('BookAuthorJoin');
419
+ Expect(pData.Tables).to.include('BookPrice');
420
+ Expect(pData.Tables).to.include('BookStore');
421
+ Expect(pData.Tables).to.include('BookStoreInventory');
422
+ Expect(pData.Tables).to.include('Review');
423
+ fDone();
424
+ });
425
+ }
426
+ );
427
+ }
428
+ );
429
+
430
+ // ---- Schema Deploy ----
431
+ suite
432
+ (
433
+ 'Schema Deploy',
434
+ function()
435
+ {
436
+ test
437
+ (
438
+ 'Should deploy all tables',
439
+ function(fDone)
440
+ {
441
+ this.timeout(30000);
442
+
443
+ fPost('/clone/schema/deploy', { Tables: [] },
444
+ (pError, pData) =>
445
+ {
446
+ Expect(pError).to.equal(null);
447
+ Expect(pData.Success).to.equal(true);
448
+ Expect(pData.SyncEntities).to.be.an('array');
449
+ Expect(pData.SyncEntities.length).to.be.greaterThan(0);
450
+ Expect(pData.SyncEntities).to.include('Book');
451
+ Expect(pData.SyncEntities).to.include('Author');
452
+ fDone();
453
+ });
454
+ }
455
+ );
456
+ }
457
+ );
458
+
459
+ // ---- Initial Sync (SQLite) ----
460
+ suite
461
+ (
462
+ 'Initial Sync (SQLite)',
463
+ function()
464
+ {
465
+ this.timeout(120000);
466
+
467
+ var _SyncStatus = null;
468
+ var _SyncReport = null;
469
+ var _SyncDuration = 0;
470
+
471
+ test
472
+ (
473
+ 'Should start initial sync with record cap',
474
+ function(fDone)
475
+ {
476
+ var tmpStart = Date.now();
477
+
478
+ fPost('/clone/sync/start',
479
+ {
480
+ SyncMode: 'Initial',
481
+ MaxRecordsPerEntity: 50
482
+ },
483
+ (pError, pData) =>
484
+ {
485
+ Expect(pError).to.equal(null);
486
+ Expect(pData.Success).to.equal(true);
487
+
488
+ // Wait for sync to complete
489
+ fWaitForSyncComplete(
490
+ (pWaitErr, pStatus) =>
491
+ {
492
+ Expect(pWaitErr).to.equal(null);
493
+ _SyncStatus = pStatus;
494
+ _SyncDuration = Date.now() - tmpStart;
495
+ fDone();
496
+ });
497
+ });
498
+ }
499
+ );
500
+ test
501
+ (
502
+ 'All tables should have completed',
503
+ function()
504
+ {
505
+ Expect(_SyncStatus).to.be.an('object');
506
+ Expect(_SyncStatus.Running).to.equal(false);
507
+
508
+ var tmpTableNames = Object.keys(_SyncStatus.Tables);
509
+ Expect(tmpTableNames.length).to.be.greaterThan(0);
510
+
511
+ for (var i = 0; i < tmpTableNames.length; i++)
512
+ {
513
+ var tmpTable = _SyncStatus.Tables[tmpTableNames[i]];
514
+ Expect(['Complete', 'Partial']).to.include(tmpTable.Status,
515
+ `Table ${tmpTableNames[i]} has unexpected status: ${tmpTable.Status}`);
516
+ }
517
+ }
518
+ );
519
+ test
520
+ (
521
+ 'Should have a valid sync report',
522
+ function(fDone)
523
+ {
524
+ fGet('/clone/sync/report',
525
+ (pError, pData) =>
526
+ {
527
+ Expect(pError).to.equal(null);
528
+ Expect(pData).to.be.an('object');
529
+ Expect(pData.ReportVersion).to.be.a('string');
530
+ Expect(pData.RunID).to.be.a('string');
531
+ Expect(['Success', 'Partial']).to.include(pData.Outcome);
532
+ Expect(pData.Summary).to.be.an('object');
533
+ Expect(pData.Summary.TotalTables).to.be.greaterThan(0);
534
+ Expect(pData.Tables).to.be.an('array');
535
+ Expect(pData.Tables.length).to.equal(pData.Summary.TotalTables);
536
+ Expect(pData.RunTimestamps).to.be.an('object');
537
+ Expect(pData.RunTimestamps.Start).to.be.a('string');
538
+ Expect(pData.RunTimestamps.End).to.be.a('string');
539
+ Expect(pData.RunTimestamps.DurationSeconds).to.be.at.least(0);
540
+
541
+ _SyncReport = pData;
542
+
543
+ // Record engine result
544
+ _EngineResults['SQLite'] =
545
+ {
546
+ status: 'pass',
547
+ sync_duration_ms: _SyncDuration,
548
+ records_synced: pData.Summary.TotalSynced || 0,
549
+ tables_synced: pData.Summary.TotalTables || 0
550
+ };
551
+
552
+ fDone();
553
+ });
554
+ }
555
+ );
556
+ test
557
+ (
558
+ 'Report tables should have timing data',
559
+ function()
560
+ {
561
+ Expect(_SyncReport).to.be.an('object');
562
+
563
+ for (var i = 0; i < _SyncReport.Tables.length; i++)
564
+ {
565
+ var tmpTable = _SyncReport.Tables[i];
566
+ Expect(tmpTable.Name).to.be.a('string');
567
+ Expect(['Complete', 'Partial', 'Error']).to.include(tmpTable.Status);
568
+ Expect(tmpTable).to.have.property('DurationSeconds');
569
+ }
570
+ }
571
+ );
572
+ }
573
+ );
574
+
575
+ // ---- Data Integrity (run right after initial sync, before pre-count resets) ----
576
+ suite
577
+ (
578
+ 'Data Integrity',
579
+ function()
580
+ {
581
+ this.timeout(30000);
582
+
583
+ test
584
+ (
585
+ 'Should fetch harness reference data',
586
+ function(fDone)
587
+ {
588
+ fGetHarness('/1.0/Books/Count',
589
+ (pError, pData) =>
590
+ {
591
+ Expect(pError).to.equal(null);
592
+ Expect(pData.Count).to.be.greaterThan(0);
593
+ _HarnessBookCount = pData.Count;
594
+
595
+ fGetHarness('/1.0/Authors/Count',
596
+ (pErr2, pData2) =>
597
+ {
598
+ Expect(pData2.Count).to.be.greaterThan(0);
599
+ _HarnessAuthorCount = pData2.Count;
600
+
601
+ fGetHarness('/1.0/Book/1',
602
+ (pErr3, pData3) =>
603
+ {
604
+ Expect(pData3.IDBook).to.equal(1);
605
+ _HarnessBookRecord1 = pData3;
606
+ fDone();
607
+ });
608
+ });
609
+ });
610
+ }
611
+ );
612
+ test
613
+ (
614
+ 'Local book count should match sync (capped)',
615
+ function(fDone)
616
+ {
617
+ fGet('/1.0/Books/Count',
618
+ (pError, pData) =>
619
+ {
620
+ Expect(pError).to.equal(null);
621
+ Expect(pData.Count).to.be.greaterThan(0);
622
+ // Local count should be <= remote count (capped sync means fewer or equal)
623
+ Expect(pData.Count).to.be.at.most(_HarnessBookCount);
624
+ fDone();
625
+ });
626
+ }
627
+ );
628
+ test
629
+ (
630
+ 'Local Book 1 should match harness data',
631
+ function(fDone)
632
+ {
633
+ fGet('/1.0/Book/1',
634
+ (pError, pData) =>
635
+ {
636
+ Expect(pError).to.equal(null);
637
+ Expect(pData.IDBook).to.equal(1);
638
+ Expect(pData.Title).to.equal(_HarnessBookRecord1.Title);
639
+ Expect(pData.Genre).to.equal(_HarnessBookRecord1.Genre);
640
+ fDone();
641
+ });
642
+ }
643
+ );
644
+ test
645
+ (
646
+ 'Local author count should match sync (capped)',
647
+ function(fDone)
648
+ {
649
+ fGet('/1.0/Authors/Count',
650
+ (pError, pData) =>
651
+ {
652
+ Expect(pError).to.equal(null);
653
+ Expect(pData.Count).to.be.greaterThan(0);
654
+ Expect(pData.Count).to.be.at.most(_HarnessAuthorCount);
655
+ fDone();
656
+ });
657
+ }
658
+ );
659
+ }
660
+ );
661
+
662
+ // ---- Pre-Count and Live Status ----
663
+ suite
664
+ (
665
+ 'Pre-Count and Live Status',
666
+ function()
667
+ {
668
+ this.timeout(120000);
669
+
670
+ test
671
+ (
672
+ 'Should run a fresh pipeline and capture pre-count data',
673
+ function(fDone)
674
+ {
675
+ // Reset and re-run to capture pre-count
676
+ fPost('/clone/reset', {},
677
+ (pResetErr) =>
678
+ {
679
+ fPost('/clone/session/configure',
680
+ { ServerURL: `${_HarnessBase}/1.0/` },
681
+ (pSessErr, pSessData) =>
682
+ {
683
+ Expect(pSessData.Success).to.equal(true);
684
+
685
+ fPost('/clone/schema/fetch', { Schema: _HarnessSchema },
686
+ (pSchemaErr, pSchemaData) =>
687
+ {
688
+ Expect(pSchemaData.Success).to.equal(true);
689
+
690
+ fPost('/clone/schema/deploy', { Tables: [] },
691
+ (pDeployErr, pDeployData) =>
692
+ {
693
+ Expect(pDeployData.Success).to.equal(true);
694
+
695
+ // Start sync with NO record cap so it runs long enough to capture live status
696
+ fPost('/clone/sync/start',
697
+ { SyncMode: 'Initial', MaxRecordsPerEntity: 0 },
698
+ (pSyncErr, pSyncData) =>
699
+ {
700
+ Expect(pSyncData.Success).to.equal(true);
701
+
702
+ // Poll live-status aggressively to capture pre-count and active sync data
703
+ var tmpLiveStatusSamples = [];
704
+ var tmpPollCount = 0;
705
+ var tmpMaxPolls = 60;
706
+
707
+ var fPollLive = function()
708
+ {
709
+ tmpPollCount++;
710
+ fGet('/clone/sync/live-status',
711
+ (pLiveErr, pLiveData) =>
712
+ {
713
+ if (!pLiveErr && pLiveData)
714
+ {
715
+ tmpLiveStatusSamples.push(pLiveData);
716
+ }
717
+
718
+ // Check if sync is done
719
+ fGet('/clone/sync/status',
720
+ (pStatusErr, pStatusData) =>
721
+ {
722
+ if (pStatusData && !pStatusData.Running)
723
+ {
724
+ // Verify we captured some live-status data
725
+ Expect(tmpLiveStatusSamples.length).to.be.greaterThan(0);
726
+
727
+ // Check for syncing samples
728
+ var tmpSyncingSamples = tmpLiveStatusSamples.filter(
729
+ (s) => s.Phase === 'syncing');
730
+
731
+ if (tmpSyncingSamples.length > 0)
732
+ {
733
+ // Verify pre-count data in syncing samples
734
+ var tmpWithPreCount = tmpSyncingSamples.filter(
735
+ (s) => s.PreCountGrandTotal > 0);
736
+ Expect(tmpWithPreCount.length).to.be.greaterThan(0,
737
+ 'Expected at least one live-status sample with PreCountGrandTotal > 0');
738
+
739
+ // Verify live-status structure
740
+ var tmpSample = tmpSyncingSamples[tmpSyncingSamples.length - 1];
741
+ Expect(tmpSample).to.have.property('Phase');
742
+ Expect(tmpSample).to.have.property('Message');
743
+ Expect(tmpSample).to.have.property('TotalSynced');
744
+ Expect(tmpSample).to.have.property('TotalRecords');
745
+ Expect(tmpSample).to.have.property('Elapsed');
746
+ Expect(tmpSample).to.have.property('SyncMode');
747
+ }
748
+ else
749
+ {
750
+ // Sync completed before we could sample — verify via report
751
+ fGet('/clone/sync/report',
752
+ (pRepErr, pReport) =>
753
+ {
754
+ Expect(pReport).to.be.an('object');
755
+ Expect(pReport.Summary.TotalSynced).to.be.greaterThan(0);
756
+ Expect(pReport.EventLog).to.be.an('array');
757
+ // Verify pre-count event exists
758
+ var tmpPreCountEvents = pReport.EventLog.filter(
759
+ (e) => e.Type === 'PreCountComplete');
760
+ Expect(tmpPreCountEvents.length).to.be.greaterThan(0);
761
+ return fDone();
762
+ });
763
+ return;
764
+ }
765
+
766
+ return fDone();
767
+ }
768
+
769
+ if (tmpPollCount >= tmpMaxPolls)
770
+ {
771
+ return fDone(new Error('Live status polling exceeded max polls'));
772
+ }
773
+
774
+ setTimeout(fPollLive, 500);
775
+ });
776
+ });
777
+ };
778
+
779
+ // Start polling immediately
780
+ fPollLive();
781
+ });
782
+ });
783
+ });
784
+ });
785
+ });
786
+ }
787
+ );
788
+ }
789
+ );
790
+
791
+ // ---- Ongoing Sync ----
792
+ suite
793
+ (
794
+ 'Ongoing Sync',
795
+ function()
796
+ {
797
+ this.timeout(120000);
798
+
799
+ test
800
+ (
801
+ 'Should run ongoing sync after initial',
802
+ function(fDone)
803
+ {
804
+ fPost('/clone/sync/start',
805
+ {
806
+ SyncMode: 'Ongoing',
807
+ MaxRecordsPerEntity: 50
808
+ },
809
+ (pError, pData) =>
810
+ {
811
+ Expect(pError).to.equal(null);
812
+ Expect(pData.Success).to.equal(true);
813
+
814
+ fWaitForSyncComplete(
815
+ (pWaitErr, pStatus) =>
816
+ {
817
+ Expect(pWaitErr).to.equal(null);
818
+ Expect(pStatus.Running).to.equal(false);
819
+
820
+ var tmpTableNames = Object.keys(pStatus.Tables);
821
+ for (var i = 0; i < tmpTableNames.length; i++)
822
+ {
823
+ var tmpTable = pStatus.Tables[tmpTableNames[i]];
824
+ Expect(['Complete', 'Partial']).to.include(tmpTable.Status,
825
+ `Ongoing sync: ${tmpTableNames[i]} has status ${tmpTable.Status}`);
826
+ }
827
+
828
+ fDone();
829
+ });
830
+ });
831
+ }
832
+ );
833
+ test
834
+ (
835
+ 'Ongoing sync report should show success',
836
+ function(fDone)
837
+ {
838
+ fGet('/clone/sync/report',
839
+ (pError, pData) =>
840
+ {
841
+ Expect(pError).to.equal(null);
842
+ Expect(['Success', 'Partial']).to.include(pData.Outcome);
843
+ fDone();
844
+ });
845
+ }
846
+ );
847
+ }
848
+ );
849
+
850
+ // ---- Stop Sync ----
851
+ suite
852
+ (
853
+ 'Stop Sync',
854
+ function()
855
+ {
856
+ this.timeout(120000);
857
+
858
+ test
859
+ (
860
+ 'Should be able to stop a sync in progress',
861
+ function(fDone)
862
+ {
863
+ // Reset and start fresh
864
+ fPost('/clone/reset', {},
865
+ () =>
866
+ {
867
+ fPost('/clone/session/configure',
868
+ { ServerURL: `${_HarnessBase}/1.0/` },
869
+ () =>
870
+ {
871
+ fPost('/clone/schema/fetch', { Schema: _HarnessSchema },
872
+ () =>
873
+ {
874
+ fPost('/clone/schema/deploy', { Tables: [] },
875
+ () =>
876
+ {
877
+ // Start sync with NO record cap (will take a while)
878
+ fPost('/clone/sync/start',
879
+ { SyncMode: 'Initial', MaxRecordsPerEntity: 0 },
880
+ (pSyncErr, pSyncData) =>
881
+ {
882
+ Expect(pSyncData.Success).to.equal(true);
883
+
884
+ // Wait a moment, then stop
885
+ setTimeout(() =>
886
+ {
887
+ fPost('/clone/sync/stop', {},
888
+ (pStopErr, pStopData) =>
889
+ {
890
+ Expect(pStopData.Success).to.equal(true);
891
+
892
+ // Wait for sync to actually stop
893
+ var fPollStop = function()
894
+ {
895
+ fGet('/clone/sync/status',
896
+ (pPollErr, pPollData) =>
897
+ {
898
+ if (pPollData && !pPollData.Running)
899
+ {
900
+ // Get report
901
+ fGet('/clone/sync/report',
902
+ (pRepErr, pReport) =>
903
+ {
904
+ if (pReport && pReport.Outcome)
905
+ {
906
+ Expect(['Stopped', 'Partial', 'Success']).to.include(pReport.Outcome);
907
+ }
908
+ return fDone();
909
+ });
910
+ }
911
+ else
912
+ {
913
+ setTimeout(fPollStop, 1000);
914
+ }
915
+ });
916
+ };
917
+ setTimeout(fPollStop, 1000);
918
+ });
919
+ }, 3000);
920
+ });
921
+ });
922
+ });
923
+ });
924
+ });
925
+ }
926
+ );
927
+ }
928
+ );
929
+
930
+ // ---- Reset ----
931
+ suite
932
+ (
933
+ 'Reset',
934
+ function()
935
+ {
936
+ this.timeout(30000);
937
+
938
+ test
939
+ (
940
+ 'Should reset the database',
941
+ function(fDone)
942
+ {
943
+ fPost('/clone/reset', {},
944
+ (pError, pData) =>
945
+ {
946
+ Expect(pError).to.equal(null);
947
+ // After reset, status should show no tables
948
+ fGet('/clone/sync/status',
949
+ (pStatusErr, pStatusData) =>
950
+ {
951
+ Expect(pStatusData.Running).to.equal(false);
952
+ fDone();
953
+ });
954
+ });
955
+ }
956
+ );
957
+ test
958
+ (
959
+ 'Connection should still work after reset',
960
+ function(fDone)
961
+ {
962
+ fGet('/clone/connection/status',
963
+ (pError, pData) =>
964
+ {
965
+ Expect(pError).to.equal(null);
966
+ Expect(pData.Connected).to.equal(true);
967
+ fDone();
968
+ });
969
+ }
970
+ );
971
+ }
972
+ );
973
+
974
+ // ---- Storage Engine: MySQL (conditional) ----
975
+ suite
976
+ (
977
+ 'Storage Engine: MySQL',
978
+ function()
979
+ {
980
+ this.timeout(120000);
981
+
982
+ var _MysqlAvailable = false;
983
+
984
+ suiteSetup
985
+ (
986
+ function()
987
+ {
988
+ if (_RequestedEngines.indexOf('mysql') < 0 || !process.env.MYSQL_HOST)
989
+ {
990
+ _EngineResults['MySQL'] = { status: 'skip', reason: 'MYSQL_HOST not set' };
991
+ this.skip();
992
+ }
993
+ else
994
+ {
995
+ _MysqlAvailable = true;
996
+ }
997
+ }
998
+ );
999
+
1000
+ test
1001
+ (
1002
+ 'Should sync via MySQL',
1003
+ function(fDone)
1004
+ {
1005
+ if (!_MysqlAvailable) return this.skip();
1006
+
1007
+ var tmpConfig =
1008
+ {
1009
+ Provider: 'MySQL',
1010
+ Config:
1011
+ {
1012
+ Server: process.env.MYSQL_HOST || 'localhost',
1013
+ Port: parseInt(process.env.MYSQL_PORT, 10) || 3306,
1014
+ User: process.env.MYSQL_USER || 'root',
1015
+ Password: process.env.MYSQL_PASSWORD || '',
1016
+ Database: process.env.MYSQL_DATABASE || 'retold_cloner_test',
1017
+ ConnectionPoolLimit: 5
1018
+ }
1019
+ };
1020
+
1021
+ fRunFullPipeline(tmpConfig, 20,
1022
+ (pError, pResult) =>
1023
+ {
1024
+ if (pError)
1025
+ {
1026
+ _EngineResults['MySQL'] = { status: 'fail', error: pError.message };
1027
+ return fDone(pError);
1028
+ }
1029
+
1030
+ Expect(pResult.report).to.be.an('object');
1031
+ Expect(['Success', 'Partial']).to.include(pResult.report.Outcome);
1032
+
1033
+ _EngineResults['MySQL'] =
1034
+ {
1035
+ status: 'pass',
1036
+ sync_duration_ms: pResult.syncDuration,
1037
+ records_synced: pResult.report.Summary.TotalSynced || 0,
1038
+ tables_synced: pResult.report.Summary.TotalTables || 0
1039
+ };
1040
+
1041
+ fDone();
1042
+ });
1043
+ }
1044
+ );
1045
+ }
1046
+ );
1047
+
1048
+ // ---- Storage Engine: PostgreSQL (conditional) ----
1049
+ suite
1050
+ (
1051
+ 'Storage Engine: PostgreSQL',
1052
+ function()
1053
+ {
1054
+ this.timeout(120000);
1055
+
1056
+ var _PgAvailable = false;
1057
+
1058
+ suiteSetup
1059
+ (
1060
+ function()
1061
+ {
1062
+ if (_RequestedEngines.indexOf('postgresql') < 0 || !process.env.POSTGRESQL_HOST)
1063
+ {
1064
+ _EngineResults['PostgreSQL'] = { status: 'skip', reason: 'POSTGRESQL_HOST not set' };
1065
+ this.skip();
1066
+ }
1067
+ else
1068
+ {
1069
+ _PgAvailable = true;
1070
+ }
1071
+ }
1072
+ );
1073
+
1074
+ test
1075
+ (
1076
+ 'Should sync via PostgreSQL',
1077
+ function(fDone)
1078
+ {
1079
+ if (!_PgAvailable) return this.skip();
1080
+
1081
+ var tmpConfig =
1082
+ {
1083
+ Provider: 'PostgreSQL',
1084
+ Config:
1085
+ {
1086
+ Server: process.env.POSTGRESQL_HOST || 'localhost',
1087
+ Port: parseInt(process.env.POSTGRESQL_PORT, 10) || 5432,
1088
+ User: process.env.POSTGRESQL_USER || 'postgres',
1089
+ Password: process.env.POSTGRESQL_PASSWORD || '',
1090
+ Database: process.env.POSTGRESQL_DATABASE || 'retold_cloner_test',
1091
+ ConnectionPoolLimit: 5
1092
+ }
1093
+ };
1094
+
1095
+ fRunFullPipeline(tmpConfig, 20,
1096
+ (pError, pResult) =>
1097
+ {
1098
+ if (pError)
1099
+ {
1100
+ _EngineResults['PostgreSQL'] = { status: 'fail', error: pError.message };
1101
+ return fDone(pError);
1102
+ }
1103
+
1104
+ Expect(pResult.report).to.be.an('object');
1105
+ Expect(['Success', 'Partial']).to.include(pResult.report.Outcome);
1106
+
1107
+ _EngineResults['PostgreSQL'] =
1108
+ {
1109
+ status: 'pass',
1110
+ sync_duration_ms: pResult.syncDuration,
1111
+ records_synced: pResult.report.Summary.TotalSynced || 0,
1112
+ tables_synced: pResult.report.Summary.TotalTables || 0
1113
+ };
1114
+
1115
+ fDone();
1116
+ });
1117
+ }
1118
+ );
1119
+ }
1120
+ );
1121
+
1122
+ // ---- Storage Engine: MSSQL (conditional) ----
1123
+ suite
1124
+ (
1125
+ 'Storage Engine: MSSQL',
1126
+ function()
1127
+ {
1128
+ this.timeout(120000);
1129
+
1130
+ var _MssqlAvailable = false;
1131
+
1132
+ suiteSetup
1133
+ (
1134
+ function()
1135
+ {
1136
+ if (_RequestedEngines.indexOf('mssql') < 0 || !process.env.MSSQL_HOST)
1137
+ {
1138
+ _EngineResults['MSSQL'] = { status: 'skip', reason: 'MSSQL_HOST not set' };
1139
+ this.skip();
1140
+ }
1141
+ else
1142
+ {
1143
+ _MssqlAvailable = true;
1144
+ }
1145
+ }
1146
+ );
1147
+
1148
+ test
1149
+ (
1150
+ 'Should sync via MSSQL',
1151
+ function(fDone)
1152
+ {
1153
+ if (!_MssqlAvailable) return this.skip();
1154
+
1155
+ var tmpConfig =
1156
+ {
1157
+ Provider: 'MSSQL',
1158
+ Config:
1159
+ {
1160
+ Server: process.env.MSSQL_HOST || 'localhost',
1161
+ Port: parseInt(process.env.MSSQL_PORT, 10) || 1433,
1162
+ User: process.env.MSSQL_USER || 'sa',
1163
+ Password: process.env.MSSQL_PASSWORD || '',
1164
+ Database: process.env.MSSQL_DATABASE || 'retold_cloner_test'
1165
+ }
1166
+ };
1167
+
1168
+ fRunFullPipeline(tmpConfig, 20,
1169
+ (pError, pResult) =>
1170
+ {
1171
+ if (pError)
1172
+ {
1173
+ _EngineResults['MSSQL'] = { status: 'fail', error: pError.message };
1174
+ return fDone(pError);
1175
+ }
1176
+
1177
+ Expect(pResult.report).to.be.an('object');
1178
+ Expect(['Success', 'Partial']).to.include(pResult.report.Outcome);
1179
+
1180
+ _EngineResults['MSSQL'] =
1181
+ {
1182
+ status: 'pass',
1183
+ sync_duration_ms: pResult.syncDuration,
1184
+ records_synced: pResult.report.Summary.TotalSynced || 0,
1185
+ tables_synced: pResult.report.Summary.TotalTables || 0
1186
+ };
1187
+
1188
+ fDone();
1189
+ });
1190
+ }
1191
+ );
1192
+ }
1193
+ );
1194
+
1195
+ // ---- Expose engine results for the runner ----
1196
+ suiteTeardown
1197
+ (
1198
+ function()
1199
+ {
1200
+ // Write engine results to env so the runner can pick them up
1201
+ process.env.ENGINE_RESULTS = JSON.stringify(_EngineResults);
1202
+ }
1203
+ );
1204
+ }
1205
+ );