/** * 数据迁移接口的端到端测试。 * * 起两个真实后端进程:A 有业务数据(源部署),B 是全新部署(目标)。 * 全程只走 HTTP,验证: * ① 导出接口真的能流出一个数据包(不是把整个库塞进内存再吐) * ② 预览接口给出的报告与真导结果一致,且预览不写线上库 * ③ 导入后业务表行数对上,系统表(users)仍是 B 自己的 * ④ 同一包再导一次:新增 0 行 * ⑤ 没有 data.transfer 权限的业务员拿到 403 * * 注意:这里必须真的 spawn 进程,所以在受限沙箱里会拿到 EPERM —— 属于已知边界。 */ const test = require('node:test') const assert = require('node:assert/strict') const fs = require('node:fs') const os = require('node:os') const path = require('node:path') const crypto = require('node:crypto') const { spawn } = require('node:child_process') const BACKEND = path.join(__dirname, '..') const { readBundle } = require('../lib/dataTransfer/bundle') const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'happy-api-transfer-')) const sourceDir = path.join(tmpRoot, 'source') const targetDir = path.join(tmpRoot, 'target') const bundlePath = path.join(tmpRoot, 'bundle.hdata.gz') /* 源部署:先用库模块建库并灌一点真实形状的数据 */ process.env.DATA_DIR = sourceDir const sourceDb = require('../db/db') const PORT_A = 20000 + Math.floor(Math.random() * 8000) const PORT_B = PORT_A + 1 function seed() { const now = '2026-01-01T00:00:00.000Z' const admin = sourceDb.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get() sourceDb.prepare('INSERT INTO suppliers (name, created_at) VALUES (?, ?)').run('宁波腾达', now) sourceDb.prepare('INSERT INTO product_groups (name, created_at) VALUES (?, ?)').run('扭矩扳手', now) sourceDb.prepare('INSERT INTO products (sku, name, price, currency, supplier_id, group_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)') .run('TD-100', '扭矩扳手 100N', 88, 'CNY', 1, 1, now) sourceDb.prepare('INSERT INTO products (sku, name, price, currency, supplier_id, created_at) VALUES (?, ?, ?, ?, ?, ?)') .run('TD-200', '扭矩扳手 200N', 128, 'CNY', 1, now) sourceDb.prepare('INSERT INTO customers (company, country, owner_id, created_by, org_id, pool_status, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') .run('ACME GmbH', 'DE', admin.id, admin.id, admin.org_id || 1, 'owned', 'active', now) sourceDb.prepare('INSERT INTO orders (order_no, customer_id, order_date, total_amount, currency, status, owner_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') .run('PO-2026-001', 1, '2026-01-02', 880, 'USD', 'confirmed', admin.id, now) sourceDb.prepare('INSERT INTO order_items (order_id, sku, product_name, qty, unit_price, total_price, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)') .run(1, 'TD-100', '扭矩扳手 100N', 10, 88, 880, now) } function startServer(dataDir, port) { const child = spawn(process.execPath, ['server.js'], { cwd: BACKEND, env: { ...process.env, DATA_DIR: dataDir, PORT: String(port) }, stdio: 'ignore' }) child.on('error', () => { /* 由健康检查超时统一报错 */ }) return child } async function waitForHealth(port, timeoutMs = 20000) { const started = Date.now() while (Date.now() - started < timeoutMs) { try { const response = await fetch(`http://127.0.0.1:${port}/api/health`) if (response.ok) return true } catch { /* 还没起来 */ } await new Promise(resolve => setTimeout(resolve, 200)) } throw new Error(`端口 ${port} 的后端在 ${timeoutMs}ms 内没有就绪`) } async function login(port, username = 'admin', password = 'admin123') { const response = await fetch(`http://127.0.0.1:${port}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username, password }) }) assert.equal(response.status, 200, `登录失败:${await response.text()}`) const cookies = typeof response.headers.getSetCookie === 'function' ? response.headers.getSetCookie() : [response.headers.get('set-cookie')] return cookies.filter(Boolean).map(item => item.split(';')[0]).join('; ') } function stopServer(child) { try { child.kill() } catch { /* 已退出 */ } } test('数据迁移接口端到端:导出 → 预览 → 导入 → 去重 → 权限', async () => { seed() sourceDb.close() const serverA = startServer(sourceDir, PORT_A) const serverB = startServer(targetDir, PORT_B) try { await waitForHealth(PORT_A) await waitForHealth(PORT_B) /* ---------------- 导出 ---------------- */ const cookieA = await login(PORT_A) const exportResponse = await fetch(`http://127.0.0.1:${PORT_A}/api/data/export?categories=all`, { headers: { cookie: cookieA } }) assert.equal(exportResponse.status, 200) assert.match(exportResponse.headers.get('content-type') || '', /gzip/) const bytes = Buffer.from(await exportResponse.arrayBuffer()) fs.writeFileSync(bundlePath, bytes) assert.ok(bytes.length > 200, '导出的包不应该是空的') assert.equal(bytes[0], 0x1f, '导出内容应是 gzip') /* ---------------- 只导订单:包内不应出现产品 ---------------- */ const ordersResponse = await fetch(`http://127.0.0.1:${PORT_A}/api/data/export?categories=orders`, { headers: { cookie: cookieA } }) assert.equal(ordersResponse.status, 200) const ordersFile = path.join(tmpRoot, 'orders.hdata.gz') fs.writeFileSync(ordersFile, Buffer.from(await ordersResponse.arrayBuffer())) const ordersBundle = await readBundle(ordersFile) assert.deepEqual([...ordersBundle.rows.keys()].sort(), ['order_items', 'orders']) /* ---------------- 目录接口 ---------------- */ const catalog = await (await fetch(`http://127.0.0.1:${PORT_A}/api/data/catalog`, { headers: { cookie: cookieA } })).json() assert.equal(catalog.categories.length, 5) assert.ok(catalog.categories.find(item => item.key === 'catalog').tables.length >= 10) assert.ok(catalog.categories.find(item => item.key === 'catalog').tables.some(item => item.rows > 0)) const cookieB = await login(PORT_B) /* ---------------- 换类别预览 / 取消上传 ---------------- */ /* 界面上的类别勾选走 replan:包已经在服务器上,换类别不该重传整个文件 */ const probe = new FormData() probe.append('file', new Blob([fs.readFileSync(bundlePath)], { type: 'application/gzip' }), 'bundle.hdata.gz') const probePreview = await (await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/preview`, { method: 'POST', headers: { cookie: cookieB }, body: probe })).json() const replan = async (categories) => (await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/replan`, { method: 'POST', headers: { cookie: cookieB, 'content-type': 'application/json' }, body: JSON.stringify({ id: probePreview.id, categories }) })).json() const onlyCustomers = await replan(['customers']) assert.ok(onlyCustomers.plan.tables.length > 0) assert.ok(onlyCustomers.plan.tables.every(item => item.category === 'customers'), '选客户类别时不应带出订单表') const onlyOrders = await replan(['orders']) assert.ok(onlyOrders.plan.tables.every(item => item.category === 'orders')) assert.equal(onlyOrders.plan.totals.rows, 2, '订单类别只有 order + order_item 两行') assert.deepEqual(onlyOrders.plan.stubbed.map(item => item.table), ['customers'], '只导订单应给客户补壳') assert.equal(onlyOrders.plan.totals.insert, 2) const stillEmpty = await (await fetch(`http://127.0.0.1:${PORT_B}/api/dashboard`, { headers: { cookie: cookieB } })).json() assert.equal(stillEmpty.stats.products, 0, '换类别预览同样不能写库') /* 取消:临时包应被丢弃,之后再用这个 id 导入应拿到 404 */ const cancelled = await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/${probePreview.id}`, { method: 'DELETE', headers: { cookie: cookieB } }) assert.equal(cancelled.status, 200) const afterCancel = await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/apply`, { method: 'POST', headers: { cookie: cookieB, 'content-type': 'application/json' }, body: JSON.stringify({ id: probePreview.id, categories: ['orders'] }) }) assert.equal(afterCancel.status, 404, '已取消的数据包不能再导入') /* ---------------- 预览(不写库) ---------------- */ const form = new FormData() form.append('file', new Blob([fs.readFileSync(bundlePath)], { type: 'application/gzip' }), 'bundle.hdata.gz') const previewResponse = await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/preview`, { method: 'POST', headers: { cookie: cookieB }, body: form }) const preview = await previewResponse.json() assert.equal(previewResponse.status, 200, JSON.stringify(preview)) assert.ok(preview.id, '预览应返回任务标识') assert.equal(preview.plan.totals.failed, 0, JSON.stringify(preview.plan.tables)) assert.equal(preview.plan.totals.insert, preview.plan.totals.rows, '首次导入应全部是新增') /* 预览不能写线上库 */ const dashboardBefore = await (await fetch(`http://127.0.0.1:${PORT_B}/api/dashboard`, { headers: { cookie: cookieB } })).json() assert.equal(dashboardBefore.stats.products, 0, '预览阶段不应写入产品') /* ---------------- 真导 ---------------- */ const applyResponse = await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/apply`, { method: 'POST', headers: { cookie: cookieB, 'content-type': 'application/json' }, body: JSON.stringify({ id: preview.id, categories: preview.availableCategories }) }) const applied = await applyResponse.json() assert.equal(applyResponse.status, 200, JSON.stringify(applied)) assert.equal(applied.plan.totals.insert, preview.plan.totals.insert, '预览与真导的新增行数必须一致') assert.ok(applied.backup && fs.existsSync(applied.backup), '导入前必须留下数据库备份') const dashboardAfter = await (await fetch(`http://127.0.0.1:${PORT_B}/api/dashboard`, { headers: { cookie: cookieB } })).json() assert.equal(dashboardAfter.stats.products, 2) assert.equal(dashboardAfter.stats.suppliers, 1) assert.equal(dashboardAfter.stats.customers, 1) assert.equal(dashboardAfter.stats.orders, 1) /* 客户归属 = B 的 admin(导入人),而不是源库的用户 id */ const me = await (await fetch(`http://127.0.0.1:${PORT_B}/api/auth/me`, { headers: { cookie: cookieB } })).json() const customerDetail = await (await fetch(`http://127.0.0.1:${PORT_B}/api/customers/1`, { headers: { cookie: cookieB } })).json() assert.equal(customerDetail.customer.owner_id, me.user.id) /* ---------------- 重复导入:新增 0 ---------------- */ const form2 = new FormData() form2.append('file', new Blob([fs.readFileSync(bundlePath)], { type: 'application/gzip' }), 'bundle.hdata.gz') const preview2 = await (await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/preview`, { method: 'POST', headers: { cookie: cookieB }, body: form2 })).json() const apply2 = await (await fetch(`http://127.0.0.1:${PORT_B}/api/data/import/apply`, { method: 'POST', headers: { cookie: cookieB, 'content-type': 'application/json' }, body: JSON.stringify({ id: preview2.id, categories: preview2.availableCategories }) })).json() assert.equal(apply2.plan.totals.insert, 0, '重复导入不应新增任何行') /* ---------------- 权限:业务员拿不到 ---------------- */ const Database = require('better-sqlite3-multiple-ciphers') const salt = crypto.randomBytes(16).toString('hex') const hash = crypto.scryptSync('rep123456', salt, 64).toString('hex') const direct = new Database(path.join(targetDir, 'trade.db')) direct.pragma('busy_timeout = 5000') direct.prepare(`INSERT INTO users (username, password_hash, display_name, role, is_active, created_at) VALUES (?, ?, ?, ?, 1, ?)`).run('rep', `scrypt$${salt}$${hash}`, '业务员', 'sales_rep', new Date().toISOString()) direct.close() const repCookie = await login(PORT_B, 'rep', 'rep123456') const denied = await fetch(`http://127.0.0.1:${PORT_B}/api/data/catalog`, { headers: { cookie: repCookie } }) assert.equal(denied.status, 403, '没有 data.transfer 权限的账号必须被挡住') const anonymous = await fetch(`http://127.0.0.1:${PORT_B}/api/data/catalog`) assert.equal(anonymous.status, 401) } finally { stopServer(serverA) stopServer(serverB) setTimeout(() => { try { fs.rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* Windows 上可能还有句柄 */ } }, 500).unref() } })