feat(data): 业务数据与系统数据分离的导入导出
数据迁移不再整体替换 trade.db —— 那条路会把账号、权限组、组织、设置(含大模型密钥)、 登录会话与审计日志一起搬到目标部署。改为按类别搬运业务数据,系统数据永远是目标部署自己那份。 - backend/lib/dataTransfer:表目录(30 张业务表,依赖顺序)、gzip JSONL 数据包(每表 sha256)、导入内核 - 归属统一给导入人;冲突跳过不覆盖;父记录按 __refs 自然键快照补壳 - 流水表(price_history)按整行内容判重:用 (sku, changed_at) 当键实测 643 行丢 186 行 - 预览在库副本上真跑一遍导入,报告与真导同源;导入前 VACUUM INTO 备份,单事务写入 - 新权限点 data.transfer(仅超管组),导出/导入均写审计 - 前端「数据迁移」页:按类别勾选导出、上传后预览逐表明细、确认导入;移除设置里的静态占位「数据交换」 - 测试:单测 7 项(含目录外键顺序、BLOB、重复导入、包被改坏)、API 端到端 1 项(两个真实实例互传) - 用真实库副本彩排:3854 行 0 失败,29 张业务表行数全部一致,包 1.22 MB
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 数据迁移接口的端到端测试。
|
||||
*
|
||||
* 起两个真实后端进程: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 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 cookieB = await login(PORT_B)
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 业务数据导入导出的回归测试。
|
||||
*
|
||||
* 全程在临时 DATA_DIR 里跑,不碰 backend/data/trade.db。
|
||||
* 覆盖:
|
||||
* ① 系统表绝不进包(users / settings / audit_log …)
|
||||
* ② 导出 → 导入到「另一个空部署」后行数与内容一致,BLOB 完整
|
||||
* ③ 重复导入同一包:全部跳过,不产生重复数据(冲突不覆盖)
|
||||
* ④ 只导订单:客户自动补壳、产品不跟着进来
|
||||
* ⑤ 包被改坏:校验失败并拒绝导入
|
||||
*/
|
||||
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 zlib = require('node:zlib')
|
||||
|
||||
const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'happy-transfer-'))
|
||||
process.env.DATA_DIR = DATA_DIR
|
||||
|
||||
const db = require('../db/db')
|
||||
const transfer = require('../lib/dataTransfer')
|
||||
const { readBundle, writeBundleFile, foreignKeys } = require('../lib/dataTransfer/bundle')
|
||||
const { TABLES } = require('../lib/dataTransfer/catalog')
|
||||
|
||||
const workDir = path.join(DATA_DIR, 'work')
|
||||
fs.mkdirSync(workDir, { recursive: true })
|
||||
|
||||
const IMAGE_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff])
|
||||
|
||||
function seed() {
|
||||
const now = '2026-01-01T00:00:00.000Z'
|
||||
const admin = db.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get()
|
||||
db.prepare('INSERT INTO suppliers (name, created_at) VALUES (?, ?)').run('宁波腾达', now)
|
||||
db.prepare('INSERT INTO product_groups (name, group_key, created_at) VALUES (?, ?, ?)').run('扭矩扳手', 'torque', now)
|
||||
db.prepare(`INSERT INTO products (sku, name, price, currency, supplier_id, group_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`).run('TD-100', '扭矩扳手 100N', 88, 'CNY', 1, 1, now)
|
||||
db.prepare(`INSERT INTO products (sku, name, price, currency, supplier_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`).run('TD-200', '扭矩扳手 200N', 128, 'CNY', 1, now)
|
||||
db.prepare('INSERT INTO product_packaging (sku, carton_qty, created_at) VALUES (?, ?, ?)').run('TD-100', 10, now)
|
||||
db.prepare('INSERT INTO product_images (sku, filename, created_at) VALUES (?, ?, ?)').run('TD-100', 'a.png', now)
|
||||
db.prepare('INSERT INTO product_image_files (image_id, bytes, size, stored_at) VALUES (?, ?, ?, ?)')
|
||||
.run(1, IMAGE_BYTES, IMAGE_BYTES.length, now)
|
||||
db.prepare('INSERT INTO price_history (sku, old_price, new_price, changed_at) VALUES (?, ?, ?, ?)')
|
||||
.run('TD-100', 80, 88, now)
|
||||
db.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)
|
||||
db.prepare('INSERT INTO contacts (customer_id, name, email, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(1, 'Hans', '[email protected]', now)
|
||||
db.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)
|
||||
db.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)
|
||||
}
|
||||
|
||||
/** 用「源库的副本 + 清空业务表」模拟另一台全新部署:schema 与线上完全一致,只有系统数据 */
|
||||
function cloneEmptyDeployment(source, label) {
|
||||
const file = path.join(workDir, `${label}.db`)
|
||||
source.exec(`VACUUM INTO '${file.replace(/'/g, "''")}'`)
|
||||
const Database = require('better-sqlite3-multiple-ciphers')
|
||||
const copy = new Database(file)
|
||||
copy.pragma('foreign_keys = ON')
|
||||
for (const table of ['order_items', 'orders', 'contacts', 'customers', 'price_history', 'product_image_files',
|
||||
'product_images', 'product_packaging', 'products', 'product_groups', 'suppliers', 'contracts']) {
|
||||
copy.exec(`DELETE FROM "${table}"`)
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
try { db.close() } catch { /* 已关闭 */ }
|
||||
try { fs.rmSync(DATA_DIR, { recursive: true, force: true }) } catch { /* Windows 偶发占用 */ }
|
||||
})
|
||||
|
||||
test('导出包只含业务表,系统表一律不出现', async () => {
|
||||
seed()
|
||||
const file = path.join(workDir, 'all.hdata.gz')
|
||||
await writeBundleFile(db, file, { categories: ['all'], exporter: db.prepare('SELECT * FROM users LIMIT 1').get() })
|
||||
const bundle = await readBundle(file)
|
||||
|
||||
for (const table of ['users', 'sessions', 'settings', 'audit_log', 'organizations', 'permission_groups']) {
|
||||
assert.equal(bundle.rows.has(table), false, `${table} 不应出现在数据包里`)
|
||||
}
|
||||
assert.equal(bundle.rows.get('products').length, 2)
|
||||
assert.equal(bundle.rows.get('orders').length, 1)
|
||||
assert.deepEqual(bundle.categories.sort(), ['catalog', 'customers', 'orders', 'prospects', 'todos'])
|
||||
})
|
||||
|
||||
test('导入到另一台空部署:行数一致、归属给导入人、BLOB 完整', async () => {
|
||||
const file = path.join(workDir, 'all.hdata.gz')
|
||||
const target = cloneEmptyDeployment(db, 'target-all')
|
||||
const importer = target.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get()
|
||||
|
||||
const result = await transfer.applyImport(target, file, {
|
||||
user: importer,
|
||||
backupDir: path.join(workDir, 'backups')
|
||||
})
|
||||
|
||||
assert.ok(result.backup && fs.existsSync(result.backup), '导入前必须留一份备份')
|
||||
assert.equal(result.plan.totals.failed, 0)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM products').get().n, 2)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM suppliers').get().n, 1)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM orders').get().n, 1)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM customers').get().n, 1)
|
||||
|
||||
const customer = target.prepare('SELECT * FROM customers WHERE company = ?').get('ACME GmbH')
|
||||
assert.equal(customer.owner_id, importer.id, '客户归属应改写为导入人')
|
||||
|
||||
const bytes = target.prepare('SELECT bytes FROM product_image_files').get().bytes
|
||||
assert.deepEqual(Buffer.from(bytes), IMAGE_BYTES, 'BLOB 必须逐字节一致')
|
||||
|
||||
/* 系统数据没被覆盖:导入前后用户表还是目标库自己那份 */
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM users').get().n, 1)
|
||||
|
||||
target.close()
|
||||
})
|
||||
|
||||
test('同一包再导一次:全部跳过,不产生重复', async () => {
|
||||
const file = path.join(workDir, 'all.hdata.gz')
|
||||
const target = cloneEmptyDeployment(db, 'target-repeat')
|
||||
const importer = target.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get()
|
||||
|
||||
const first = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') })
|
||||
assert.ok(first.plan.totals.insert > 0)
|
||||
|
||||
const second = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') })
|
||||
assert.equal(second.plan.totals.insert, 0, '第二次导入不应新增任何行')
|
||||
assert.equal(second.plan.totals.skip, first.plan.totals.rows)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM products').get().n, 2)
|
||||
|
||||
target.close()
|
||||
})
|
||||
|
||||
test('只导订单:客户自动补壳,产品数据不跟进来', async () => {
|
||||
const file = path.join(workDir, 'orders-only.hdata.gz')
|
||||
await writeBundleFile(db, file, { categories: ['orders'] })
|
||||
const bundle = await readBundle(file)
|
||||
assert.deepEqual([...bundle.rows.keys()].sort(), ['order_items', 'orders'], '只选订单类别时不应带出产品/客户')
|
||||
|
||||
const target = cloneEmptyDeployment(db, 'target-orders')
|
||||
const importer = target.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get()
|
||||
const result = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') })
|
||||
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM orders').get().n, 1)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM order_items').get().n, 1)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM products').get().n, 0, '未选产品类别就不该有产品')
|
||||
|
||||
const stub = target.prepare('SELECT * FROM customers').get()
|
||||
assert.equal(stub.company, 'ACME GmbH', '应按 __refs 快照补出客户公司名')
|
||||
assert.equal(stub.owner_id, importer.id)
|
||||
assert.deepEqual(result.plan.stubbed.map(item => item.table), ['customers'])
|
||||
|
||||
target.close()
|
||||
})
|
||||
|
||||
test('流水表:包内逐列相同的记录全部保留,重导仍全部跳过', async () => {
|
||||
/* 源库确实会这样:一次批量改价会在同一毫秒写两条一模一样的 price_history。
|
||||
拿 (sku, changed_at) 当去重键会把其中一条当重复丢掉(实测 643 行丢 186 行),
|
||||
所以流水表改成「整行内容比对」,且允许同一个包里出现相同的两行。 */
|
||||
const now = '2026-02-02T00:00:00.000Z'
|
||||
const insert = db.prepare('INSERT INTO price_history (sku, old_price, new_price, changed_at) VALUES (?, ?, ?, ?)')
|
||||
insert.run('TD-200', 100, 110, now)
|
||||
insert.run('TD-200', 100, 110, now)
|
||||
|
||||
const file = path.join(workDir, 'history.hdata.gz')
|
||||
await writeBundleFile(db, file, { categories: ['catalog'] })
|
||||
const bundle = await readBundle(file)
|
||||
assert.equal(bundle.rows.get('price_history').length, 3, '源库 1 条 + 新增 2 条都应进包')
|
||||
|
||||
const target = cloneEmptyDeployment(db, 'target-history')
|
||||
const importer = target.prepare('SELECT * FROM users ORDER BY id LIMIT 1').get()
|
||||
const first = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') })
|
||||
assert.equal(first.plan.totals.failed, 0)
|
||||
assert.equal(target.prepare('SELECT COUNT(*) AS n FROM price_history').get().n, 3, '逐列相同的历史记录也要全部带过去')
|
||||
|
||||
const second = await transfer.applyImport(target, file, { user: importer, backupDir: path.join(workDir, 'backups') })
|
||||
assert.equal(second.plan.totals.insert, 0, '第二次导入不应新增')
|
||||
|
||||
target.close()
|
||||
})
|
||||
|
||||
test('目录顺序满足外键依赖:父表一定排在子表前面', () => {
|
||||
/* 导入是逐表逐行插入的,顺序错了就会撞外键。这条测试保证以后新增业务表时
|
||||
有人把表插到错误位置会立刻红。 */
|
||||
const known = new Set(TABLES.map(item => item.table))
|
||||
const seen = new Set()
|
||||
for (const spec of TABLES) {
|
||||
for (const ref of foreignKeys(db, spec.table)) {
|
||||
if (ref.table === spec.table) continue
|
||||
if (!known.has(ref.table)) continue // 指向 users / organizations 等系统表的列不参与
|
||||
assert.ok(seen.has(ref.table), `${spec.table}.${ref.from} 引用的 ${ref.table} 必须排在它前面`)
|
||||
}
|
||||
seen.add(spec.table)
|
||||
}
|
||||
})
|
||||
|
||||
test('包被改坏:sha256 校验失败并拒绝导入', async () => {
|
||||
const file = path.join(workDir, 'all.hdata.gz')
|
||||
const lines = zlib.gunzipSync(fs.readFileSync(file)).toString('utf8').split('\n').filter(Boolean)
|
||||
const productLine = lines.findIndex(line => line.includes('"t":"products"') && line.includes('TD-100'))
|
||||
assert.ok(productLine > 0)
|
||||
lines[productLine] = lines[productLine].replace('TD-100', 'TD-999')
|
||||
const broken = path.join(workDir, 'broken.hdata')
|
||||
fs.writeFileSync(broken, lines.join('\n') + '\n')
|
||||
|
||||
await assert.rejects(() => readBundle(broken), /校验失败/)
|
||||
})
|
||||
Reference in New Issue
Block a user