// lib/seed-demo-data-template.test.mjs — 校验生成模板 scripts/seed-demo-data.mjs 的演示种子注入逻辑。 // 跑的是真实模板产物:复制到临时 scripts/ 下、写一个 ../config-vars.yaml、可选写 sql/seed/*.sql、再 node 执行。 // 所有会触达 DB 的用例 host/port 故意指向 127.0.0.1:1(必拒连),不会触碰真实库。 import { test } from 'node:test' import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' const TEMPLATE = fileURLToPath(new URL('../skills/plan/skeleton-gen/templates/scripts-seed-demo-data-template.mjs', import.meta.url)) // 搭一个临时目标项目:scripts/seed-demo-data.mjs + config-vars.yaml + 可选 sql/seed/*.sql,然后跑脚本。 // seedFiles 为 null 表示不创建 sql/seed 目录;为对象 { name: content } 表示创建目录并写入这些文件({} = 空目录)。 // env:传给子进程的增量环境变量;laneMarker:非 null 时写 .tmp/erp-lane-db 兜底标记。 // 基础环境强制剔除 ERP_TEST_DB_SCHEMA——外层 shell 若恰好设了该变量,"缺省行为"用例会被污染。 function run({ host = '127.0.0.1', port = '1', user = 'root', password = 'x', schemaLine = 'schema: erp_dev', seedFiles = null, env = {}, laneMarker = null, } = {}) { const dir = mkdtempSync(join(tmpdir(), 'erp-seed-')) mkdirSync(join(dir, 'scripts')) copyFileSync(TEMPLATE, join(dir, 'scripts', 'seed-demo-data.mjs')) writeFileSync( join(dir, 'config-vars.yaml'), ['database:', ` host: ${host}`, ` port: ${port}`, ` user: ${user}`, ` password: ${password}`, ' ' + schemaLine, ''].join('\n'), ) if (seedFiles !== null) { const seedDir = join(dir, 'sql', 'seed') mkdirSync(seedDir, { recursive: true }) for (const [name, content] of Object.entries(seedFiles)) { writeFileSync(join(seedDir, name), content) } } if (laneMarker !== null) { mkdirSync(join(dir, '.tmp')) writeFileSync(join(dir, '.tmp', 'erp-lane-db'), laneMarker) } const childEnv = { ...process.env, ...env } if (!('ERP_TEST_DB_SCHEMA' in env)) delete childEnv.ERP_TEST_DB_SCHEMA return spawnSync('node', [join(dir, 'scripts', 'seed-demo-data.mjs')], { encoding: 'utf8', env: childEnv }) } test('seed-demo-data: unfilled 【人工填写】 config placeholders fail before mysql is invoked', () => { for (const cfg of [ { host: '【人工填写:MySQL host】' }, { port: '【人工填写:MySQL port】' }, { user: '【人工填写:账号】' }, { password: '【人工填写:密码】' }, { schemaLine: 'schema: 【人工填写:schema 名】' }, ]) { const r = run(cfg) assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) assert.match(r.stderr, /仍是占位/, 'stderr: ' + r.stderr) } }) test('seed-demo-data: empty schema fails before mysql is invoked', () => { const r = run({ schemaLine: 'schema:' }) assert.equal(r.status, 1) assert.match(r.stderr, /database\.schema/, '应是 schema 缺失报错而非连库失败 — stderr: ' + r.stderr) }) test('seed-demo-data: missing sql/seed directory exits 0 with no-seed message', () => { const r = run({ seedFiles: null }) assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) assert.match(r.stdout, /无种子文件/, 'stdout: ' + r.stdout) }) test('seed-demo-data: empty sql/seed directory exits 0 with no-seed message', () => { const r = run({ seedFiles: {} }) assert.equal(r.status, 0, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) assert.match(r.stdout, /无种子文件/, 'stdout: ' + r.stdout) }) test('seed-demo-data: illegal seed filename fails before mysql is invoked', () => { const r = run({ seedFiles: { '01 bad.sql': '-- expect: t=0\n' } }) assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) assert.match(r.stderr, /非法种子文件名/, 'stderr: ' + r.stderr) assert.match(r.stderr, /01 bad\.sql/, '应列出违规文件名 — stderr: ' + r.stderr) // 文件名校验在任何 mysql 调用之前 —— 不应出现已进入 DB 阶段的日志。 assert.doesNotMatch(r.stdout, /目标库/, 'stdout: ' + r.stdout) }) // 缺 __ 结构的文件名(会被旧宽松正则放过,但违反 __.sql 契约)必须被拒。 test('seed-demo-data: filenames missing the __ structure are rejected', () => { for (const bad of [ 'inventory.sql', // 无 NN__ 前缀 '1__x.sql', // 单位数 NN '001__x.sql', // 三位数 NN '01-x.sql', // 连字符而非双下划线 '01_x.sql', // 单下划线而非双下划线 '01__.sql', // module_id 为空 '01__bad-id.sql', // module_id 含连字符(非 [A-Za-z0-9_]) ]) { const r = run({ seedFiles: { [bad]: '-- expect: t=0\n' } }) assert.equal(r.status, 1, `${bad} 应被拒 — stdout: ${r.stdout} stderr: ${r.stderr}`) assert.match(r.stderr, /非法种子文件名/, `${bad} — stderr: ${r.stderr}`) // 文件名校验在任何 mysql 调用之前 —— 不应进入 DB 阶段。 assert.doesNotMatch(r.stdout, /目标库/, `${bad} — stdout: ${r.stdout}`) } }) // 合法 __.sql 通过文件名校验后进入 DB 阶段(验证收紧后的正则不误伤合法名)。 test('seed-demo-data: well-formed __.sql passes filename check', () => { const r = run({ seedFiles: { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' } }) assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) assert.doesNotMatch(r.stderr, /非法种子文件名/, 'stderr: ' + r.stderr) assert.match(r.stdout, /目标库 erp_dev__test/, '串行缺省目标应为副本 erp_dev__test(非源库)— stdout: ' + r.stdout) }) test('seed-demo-data: valid seed + port 1 passes local checks then fails at DB connect', () => { const r = run({ seedFiles: { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' } }) assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) // 已通过全部本地校验、进入 DB 阶段:应打印目标库摘要,且报错是连库失败(非占位/非法名)。 assert.match(r.stdout, /目标库 erp_dev__test/, '串行缺省目标应为副本 erp_dev__test(非源库)— stdout: ' + r.stdout) assert.doesNotMatch(r.stderr, /仍是占位/, 'stderr: ' + r.stderr) assert.doesNotMatch(r.stderr, /非法种子文件名/, 'stderr: ' + r.stderr) }) // ── 注入目标副本 lane 化:取值顺序 env > .tmp/erp-lane-db marker > `__test` ── const LANE_SEED = { '01__inventory.sql': '-- expect: inventory_item=3\nSELECT 1;\n' } test('seed-demo-data: ERP_TEST_DB_SCHEMA env overrides config schema', () => { const r = run({ seedFiles: LANE_SEED, env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' } }) assert.match(r.stdout, /目标库 erp_dev_lane2/, 'stdout: ' + r.stdout) }) test('seed-demo-data: .tmp/erp-lane-db marker overrides config when env is absent, with audit log', () => { const r = run({ seedFiles: LANE_SEED, laneMarker: 'erp_dev_lane3\n' }) assert.match(r.stdout, /lane DB marker 生效: erp_dev_lane3/, 'stdout: ' + r.stdout) assert.match(r.stdout, /目标库 erp_dev_lane3/, 'stdout: ' + r.stdout) }) test('seed-demo-data: env wins over marker; blank marker falls through to config', () => { const both = run({ seedFiles: LANE_SEED, env: { ERP_TEST_DB_SCHEMA: 'erp_dev_lane2' }, laneMarker: 'erp_dev_lane3\n' }) assert.match(both.stdout, /目标库 erp_dev_lane2/, 'stdout: ' + both.stdout) assert.doesNotMatch(both.stdout, /lane DB marker 生效/, 'stdout: ' + both.stdout) const blank = run({ seedFiles: LANE_SEED, laneMarker: '\n' }) assert.match(blank.stdout, /目标库 erp_dev__test/, '空 marker 应回退到副本 erp_dev__test — stdout: ' + blank.stdout) assert.doesNotMatch(blank.stdout, /lane DB marker 生效/, 'stdout: ' + blank.stdout) }) // **安全红线**:注入目标 = 源库 → 拒绝(演示种子绝不写源库)。守卫先于任何 mysql 调用。 test('seed-demo-data: injection target equal to source schema is rejected before mysql', () => { const r = run({ seedFiles: LANE_SEED, env: { ERP_TEST_DB_SCHEMA: 'erp_dev' } }) // 与源库同名 assert.equal(r.status, 1, 'stdout: ' + r.stdout + ' stderr: ' + r.stderr) assert.match(r.stderr, /注入目标副本名与源库名相同/, 'stderr: ' + r.stderr) assert.doesNotMatch(r.stdout, /目标库/, '拒绝应发生在进入 DB 阶段之前 — stdout: ' + r.stdout) })