Files
happy-export/backend/test/data-transfer-api.test.js
T
Drsakura 5acfbcea7d chore(release): 1.2.0 —— 数据迁移功能发版
- 版本号 1.1.1 → 1.2.0(root / backend / frontend 与两个 lockfile):
  部署后 /api/health 与「系统与更新」能直接分辨线上跑的是哪份代码,否则换库前后都显示 1.1.1。
- API 测试补齐:只导订单的包内容、/api/data/catalog、换类别预览(replan)、取消上传后不可再导。
- 文档补排查表:413、版本确认、权限位缺失、SQLITE_BUSY、包过期、整体回滚。
- 已验证 data.transfer 只补给超级管理员组(admin),董事长/总经理/销售经理都拿不到。
2026-10-23 13:51:13 +08:00

250 lines
13 KiB
JavaScript

/**
* 数据迁移接口的端到端测试。
*
* 起两个真实后端进程: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()
}
})