nightly-legacy-sync.test.mjs 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. import assert from 'node:assert/strict';
  2. import { spawn } from 'node:child_process';
  3. import { createServer } from 'node:http';
  4. import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
  5. import { tmpdir } from 'node:os';
  6. import { join } from 'node:path';
  7. import test from 'node:test';
  8. async function fixture(mode) {
  9. const reportDir = await mkdtemp(join(tmpdir(), 'xiaoshu-nightly-test-'));
  10. const calls = [];
  11. const batches = new Map();
  12. const datasets = ['memory-records', 'learning-records', 'lessons', 'appointments'];
  13. const server = createServer(async (request, response) => {
  14. response.setHeader('Content-Type', 'application/json');
  15. if (request.url.endsWith('/health')) {
  16. response.statusCode = mode === '404' ? 404 : 200;
  17. response.end(JSON.stringify({ status: 'ok', service: 'xiaoshu-legacy-sync', version: 'v1' }));
  18. return;
  19. }
  20. let body = '';
  21. for await (const chunk of request) body += chunk;
  22. const { params } = JSON.parse(body);
  23. calls.push(params);
  24. let data;
  25. if (params.operation === 'ops/sync/status') {
  26. data = {
  27. configured: mode !== 'unconfigured', bridgeReachable: true,
  28. syncProtection: mode === 'unprotected' ? undefined : { conflictPolicy: 'preserve-new', atomicProjection: true },
  29. totalFailures: 0, totalConflicts: mode === 'conflicts' ? 684 : 0,
  30. datasets: datasets.map(key => ({ key, reconciled: mode !== 'unreconciled' })),
  31. };
  32. } else {
  33. assert.equal(params.conflictPolicy, 'preserve-new');
  34. const round = (batches.get(params.dataset) || 0) + 1;
  35. batches.set(params.dataset, round);
  36. data = { dataset: params.dataset, failures: 0, processed: 1, created: 1, hasMore: params.dataset === 'learning-records' && (mode === 'incomplete' || round < 2) };
  37. }
  38. response.end(JSON.stringify({ success: true, data }));
  39. });
  40. await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
  41. const origin = `http://127.0.0.1:${server.address().port}`;
  42. try {
  43. const child = spawn(process.execPath, ['scripts/run-nightly-legacy-sync.mjs'], {
  44. cwd: process.cwd(),
  45. env: { ...process.env, XIAOSHU_FUNCTION_URL: origin, XIAOSHU_LEGACY_SYNC_BASE_URL: origin,
  46. XIAOSHU_SYNC_OPERATOR_TOKEN: 'never-log-test-token', XIAOSHU_SYNC_DATASETS: datasets.join(','),
  47. XIAOSHU_SYNC_OPERATOR_EXPIRES_AT: mode === 'expired' ? '2000-01-01T00:00:00Z' : '2099-01-01T00:00:00Z',
  48. XIAOSHU_SYNC_MAX_ROUNDS: '2', XIAOSHU_SYNC_REPORT_DIR: reportDir },
  49. stdio: ['ignore', 'pipe', 'pipe'],
  50. });
  51. let output = '';
  52. child.stdout.on('data', chunk => { output += chunk; });
  53. child.stderr.on('data', chunk => { output += chunk; });
  54. const code = await new Promise(resolve => child.on('close', resolve));
  55. const files = (await readdir(reportDir)).filter(name => name.endsWith('.json'));
  56. assert.equal(files.length, 1, output);
  57. const raw = await readFile(join(reportDir, files[0]), 'utf8');
  58. assert(!raw.includes('never-log-test-token'));
  59. assert(!output.includes('never-log-test-token'));
  60. return { code, report: JSON.parse(raw), calls, batches };
  61. } finally {
  62. await new Promise(resolve => server.close(resolve));
  63. await rm(reportDir, { recursive: true, force: true });
  64. }
  65. }
  66. for (const [mode, reason] of [['404', 'bridge_http_404'], ['expired', 'expired_super_admin_token'], ['unconfigured', 'bridge_not_configured_or_signed_manifest_unavailable'], ['unprotected', 'preserve_new_conflict_protection_not_verified']]) {
  67. test(`nightly refuses writes when ${mode}, and persists the failure`, async () => {
  68. const result = await fixture(mode);
  69. assert.equal(result.code, 1);
  70. assert.equal(result.report.status, 'blocked');
  71. assert.equal(result.report.reason, reason);
  72. assert(!result.calls.some(call => call.operation === 'ops/sync/run'));
  73. });
  74. }
  75. test('nightly drains multiple pages before dependents and requires reconciliation', async () => {
  76. const result = await fixture('success');
  77. assert.equal(result.code, 0);
  78. assert.equal(result.report.status, 'success');
  79. const requested = result.calls.filter(call => call.operation === 'ops/sync/run').map(call => call.dataset);
  80. assert.deepEqual(requested, ['learning-records', 'learning-records', 'memory-records', 'appointments', 'lessons']);
  81. assert.equal(result.report.datasets['learning-records'].created, 2);
  82. });
  83. test('round limit is a failure and never starts a dependent', async () => {
  84. const result = await fixture('incomplete');
  85. assert.equal(result.code, 1);
  86. assert.equal(result.report.status, 'incomplete');
  87. assert(!result.batches.has('memory-records'));
  88. });
  89. test('retained conflicts are recorded and never called success', async () => {
  90. const result = await fixture('conflicts');
  91. assert.equal(result.code, 1);
  92. assert.equal(result.report.status, 'completed_with_conflicts');
  93. assert.equal(result.report.pendingConflicts, 684);
  94. });
  95. test('caught up without business ID checksums is not success', async () => {
  96. const result = await fixture('unreconciled');
  97. assert.equal(result.code, 1);
  98. assert.equal(result.report.status, 'awaiting_reconciliation');
  99. });