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'); });