| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101 |
- import assert from 'node:assert/strict';
- import { spawn } from 'node:child_process';
- import { createServer } from 'node:http';
- import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
- import { tmpdir } from 'node:os';
- import { join } from 'node:path';
- import test from 'node:test';
- async function fixture(mode) {
- const reportDir = await mkdtemp(join(tmpdir(), 'xiaoshu-nightly-test-'));
- const calls = [];
- const batches = new Map();
- const datasets = ['memory-records', 'learning-records', 'lessons', 'appointments'];
- const server = createServer(async (request, response) => {
- response.setHeader('Content-Type', 'application/json');
- if (request.url.endsWith('/health')) {
- response.statusCode = mode === '404' ? 404 : 200;
- response.end(JSON.stringify({ status: 'ok', service: 'xiaoshu-legacy-sync', version: 'v1' }));
- return;
- }
- let body = '';
- for await (const chunk of request) body += chunk;
- const { params } = JSON.parse(body);
- calls.push(params);
- let data;
- if (params.operation === 'ops/sync/status') {
- data = {
- configured: mode !== 'unconfigured', bridgeReachable: true,
- syncProtection: mode === 'unprotected' ? undefined : { conflictPolicy: 'preserve-new', atomicProjection: true },
- totalFailures: 0, totalConflicts: mode === 'conflicts' ? 684 : 0,
- datasets: datasets.map(key => ({ key, reconciled: mode !== 'unreconciled' })),
- };
- } else {
- assert.equal(params.conflictPolicy, 'preserve-new');
- const round = (batches.get(params.dataset) || 0) + 1;
- batches.set(params.dataset, round);
- data = { dataset: params.dataset, failures: 0, processed: 1, created: 1, hasMore: params.dataset === 'learning-records' && (mode === 'incomplete' || round < 2) };
- }
- response.end(JSON.stringify({ success: true, data }));
- });
- await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
- const origin = `http://127.0.0.1:${server.address().port}`;
- try {
- const child = spawn(process.execPath, ['scripts/run-nightly-legacy-sync.mjs'], {
- cwd: process.cwd(),
- env: { ...process.env, XIAOSHU_FUNCTION_URL: origin, XIAOSHU_LEGACY_SYNC_BASE_URL: origin,
- XIAOSHU_SYNC_OPERATOR_TOKEN: 'never-log-test-token', XIAOSHU_SYNC_DATASETS: datasets.join(','),
- XIAOSHU_SYNC_OPERATOR_EXPIRES_AT: mode === 'expired' ? '2000-01-01T00:00:00Z' : '2099-01-01T00:00:00Z',
- XIAOSHU_SYNC_MAX_ROUNDS: '2', XIAOSHU_SYNC_REPORT_DIR: reportDir },
- stdio: ['ignore', 'pipe', 'pipe'],
- });
- let output = '';
- child.stdout.on('data', chunk => { output += chunk; });
- child.stderr.on('data', chunk => { output += chunk; });
- const code = await new Promise(resolve => child.on('close', resolve));
- const files = (await readdir(reportDir)).filter(name => name.endsWith('.json'));
- assert.equal(files.length, 1, output);
- const raw = await readFile(join(reportDir, files[0]), 'utf8');
- assert(!raw.includes('never-log-test-token'));
- assert(!output.includes('never-log-test-token'));
- return { code, report: JSON.parse(raw), calls, batches };
- } finally {
- await new Promise(resolve => server.close(resolve));
- await rm(reportDir, { recursive: true, force: true });
- }
- }
- 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']]) {
- test(`nightly refuses writes when ${mode}, and persists the failure`, async () => {
- const result = await fixture(mode);
- assert.equal(result.code, 1);
- assert.equal(result.report.status, 'blocked');
- assert.equal(result.report.reason, reason);
- assert(!result.calls.some(call => call.operation === 'ops/sync/run'));
- });
- }
- test('nightly drains multiple pages before dependents and requires reconciliation', async () => {
- const result = await fixture('success');
- assert.equal(result.code, 0);
- assert.equal(result.report.status, 'success');
- const requested = result.calls.filter(call => call.operation === 'ops/sync/run').map(call => call.dataset);
- assert.deepEqual(requested, ['learning-records', 'learning-records', 'memory-records', 'appointments', 'lessons']);
- assert.equal(result.report.datasets['learning-records'].created, 2);
- });
- test('round limit is a failure and never starts a dependent', async () => {
- const result = await fixture('incomplete');
- assert.equal(result.code, 1);
- assert.equal(result.report.status, 'incomplete');
- assert(!result.batches.has('memory-records'));
- });
- test('retained conflicts are recorded and never called success', async () => {
- const result = await fixture('conflicts');
- assert.equal(result.code, 1);
- assert.equal(result.report.status, 'completed_with_conflicts');
- assert.equal(result.report.pendingConflicts, 684);
- });
- test('caught up without business ID checksums is not success', async () => {
- const result = await fixture('unreconciled');
- assert.equal(result.code, 1);
- assert.equal(result.report.status, 'awaiting_reconciliation');
- });
|